fix: address custom decor image review

Issue: #51
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-02 21:56:04 +00:00
committed by claude[bot]
parent fe8970ee84
commit 590d178e29
28 changed files with 462 additions and 307 deletions
+45 -6
View File
@@ -12,6 +12,7 @@ import math
import re
import struct
import xml.etree.ElementTree as ET
from xml.parsers import expat
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -33,6 +34,7 @@ MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4
MAX_SVG_ELEMENTS = 5_000
MAX_SVG_DEPTH = 64
MAX_SVG_ATTR_CHARS = 512_000
MAX_SVG_ATTR_VALUE_CHARS = 65_536
_SVG_TAGS = frozenset({
"svg", "g", "defs", "title", "desc", "path", "rect", "circle", "ellipse",
"line", "polyline", "polygon", "clipPath", "mask", "linearGradient",
@@ -49,6 +51,9 @@ _SVG_ATTRS = frozenset({
_LOCAL_REF = re.compile(r"^url\(#[A-Za-z_][A-Za-z0-9_.:-]*\)$")
_LENGTH = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*(?:px)?\s*$", re.I)
_SVG_NAMESPACE = "http://www.w3.org/2000/svg"
_SVG_UNIT_INTERVAL_ATTRS = frozenset({
"opacity", "fill-opacity", "stroke-opacity", "stop-opacity", "offset",
})
class DecorAssetError(ValueError):
@@ -157,15 +162,45 @@ def _svg_number(value: str | None) -> float | None:
return float(match.group(1)) if match else None
def _validate_svg(data: bytes) -> ValidatedAsset:
lowered = data.lower()
if any(token in lowered for token in (b"<!doctype", b"<!entity", b"<?xml-stylesheet")):
def _validate_svg_unit_interval(name: str, value: str) -> None:
"""Reject non-finite and out-of-range opacity/gradient values."""
raw = value.strip()
percent = raw.endswith("%")
try:
number = float(raw[:-1] if percent else raw)
except ValueError as err:
raise DecorAssetError("invalid_image", f"The SVG {name} value is invalid") from err
limit = 100 if percent else 1
if not math.isfinite(number) or number < 0 or number > limit:
raise DecorAssetError("invalid_image", f"The SVG {name} value is out of range")
def _reject_svg_declarations(data: bytes) -> None:
"""Parse with encoding-aware expat guards before building an XML tree."""
parser = expat.ParserCreate()
def reject(*_args: Any) -> None:
raise DecorAssetError(
"invalid_image", "DTD, entities and processing instructions are forbidden",
)
without_declaration = re.sub(br"^\s*<\?xml\b[^?]*\?>", b"", data, count=1, flags=re.I)
if b"<?" in without_declaration:
raise DecorAssetError("invalid_image", "XML processing instructions are forbidden")
parser.StartDoctypeDeclHandler = reject
parser.EntityDeclHandler = reject
parser.ExternalEntityRefHandler = reject
parser.ProcessingInstructionHandler = reject
try:
parser.Parse(data, True)
except DecorAssetError:
raise
except (expat.ExpatError, UnicodeError) as err:
raise DecorAssetError("invalid_image", "The SVG is not valid XML") from err
def _validate_svg(data: bytes) -> ValidatedAsset:
# Byte-substring checks are bypassable with UTF-16/UTF-32. Expat detects
# the declared/input encoding first and our handlers reject declarations
# before entity expansion can allocate an ElementTree.
_reject_svg_declarations(data)
try:
root = ET.fromstring(data)
except (ET.ParseError, UnicodeError) as err:
@@ -196,6 +231,8 @@ def _validate_svg(data: bytes) -> ValidatedAsset:
if tag not in {"title", "desc"} or len(node.text) > 4096:
raise DecorAssetError("unsupported_image", "Unsupported or oversized SVG text")
for raw_name, value in node.attrib.items():
if len(value) > MAX_SVG_ATTR_VALUE_CHARS:
raise DecorAssetError("too_large", "An SVG attribute exceeds the safety limit")
if raw_name.startswith("{"):
raise DecorAssetError("unsupported_image", "Namespaced SVG attributes are unsupported")
name = raw_name
@@ -204,6 +241,8 @@ def _validate_svg(data: bytes) -> ValidatedAsset:
raise DecorAssetError("too_large", "The SVG attributes exceed the safety limit")
if name.lower().startswith("on") or name not in _SVG_ATTRS:
raise DecorAssetError("unsupported_image", f"Unsupported SVG attribute: {name}")
if name in _SVG_UNIT_INTERVAL_ATTRS:
_validate_svg_unit_interval(name, value)
low = value.strip().lower()
if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")):
raise DecorAssetError("invalid_image", "External SVG resources are forbidden")
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "91d90ad5fe8f4f44a45669312cea92fc5c5c061316d8382ea8ec58dbef1603d8",
"fingerprint": "4386b4e9f688f89cce5a1680c9f21b315933dd897345287b8c64700400b11c11",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-ruQDQ4Jw.js",
"houseplan-assets/houseplan-card-qfwHeLJR.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291026,
"initialViewGzipBytes": 291041,
"lazyFiles": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/de-MNYUkgT1.js",
"houseplan-assets/editor-Ih3Tgb49.js",
"houseplan-assets/fr-BB7LeC_Q.js",
"houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js",
"houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/de-Dc1N8boU.js",
"houseplan-assets/editor-DWFSXWYH.js",
"houseplan-assets/fr-Cw28JMxl.js",
"houseplan-assets/houseplan-editor-runtime-BQ2FUiZP.js",
"houseplan-assets/houseplan-onboarding-runtime-OhzyGbC7.js"
],
"lazyGzipBytes": 213816,
"lazyGzipBytes": 213868,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/editor-Ih3Tgb49.js",
"houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/editor-DWFSXWYH.js",
"houseplan-assets/houseplan-editor-runtime-BQ2FUiZP.js"
],
"lazyEditorGzipBytes": 158012,
"lazyEditorGzipBytes": 158042,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/houseplan-onboarding-runtime-OhzyGbC7.js"
],
"lazyOnboardingGzipBytes": 14130,
"lazyOnboardingGzipBytes": 14131,
"lazyLocaleFiles": [
"houseplan-assets/de-MNYUkgT1.js",
"houseplan-assets/fr-BB7LeC_Q.js"
"houseplan-assets/de-Dc1N8boU.js",
"houseplan-assets/fr-Cw28JMxl.js"
],
"lazyLocaleGzipBytes": 48848,
"lazyLocaleGzipBytes": 48871,
"files": [
{
"path": "houseplan-assets/backdrop-pick-CsM7GqVe.js",
"sha256": "136838d9a113801d3d59b5198facdf568a90e9384f62133d84ee8ccfbcf91725",
"path": "houseplan-assets/backdrop-pick-Di7bhm6e.js",
"sha256": "a166e065bb3b3d4905c6e8e46c5c5157c32d03d58b8ece49caa661d78a4bf4a9",
"rawBytes": 20974,
"gzipBytes": 7174,
"gzipBytes": 7176,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-MNYUkgT1.js",
"sha256": "8414e28bb23e0cc2e8d777a97b217f8a971d002109ce38168c0890b0bd4a38ff",
"path": "houseplan-assets/de-Dc1N8boU.js",
"sha256": "d5707d18ebb3f3f423037557e1692cf038337ac60655fa6684f3b596afbce5bf",
"rawBytes": 81798,
"gzipBytes": 24696,
"gzipBytes": 24707,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Ih3Tgb49.js",
"sha256": "56f7be4294a42cb92bec171b3ea409425d70fb7ff5a4ca07ffae9190d9ea74d4",
"path": "houseplan-assets/editor-DWFSXWYH.js",
"sha256": "dcbb29ac4c28f8639044dee93688768b67d3044e6ec06dff59d028665a9e73e3",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BB7LeC_Q.js",
"sha256": "63b7104bab6abf083d5478736b8f5fe16ed7ca09edf52fa8cdeda418aa041bf8",
"path": "houseplan-assets/fr-Cw28JMxl.js",
"sha256": "8209da223a0b5fe0ea3681a1a8df7f6d2a28ea4d6376953b21be47a5b2257397",
"rawBytes": 83973,
"gzipBytes": 24152,
"gzipBytes": 24164,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-ruQDQ4Jw.js",
"sha256": "99538870102416fc6844acba2719f23a26f08d9f12fb2619d2788a83ee7d9a5b",
"rawBytes": 1033593,
"gzipBytes": 290230,
"path": "houseplan-assets/houseplan-card-qfwHeLJR.js",
"sha256": "2bd83b9350a1b4e702d28660cdc3b38a5daa3da16f9de47ddd5a839417443cbc",
"rawBytes": 1033605,
"gzipBytes": 290242,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-MNYUkgT1.js",
"houseplan-assets/editor-Ih3Tgb49.js",
"houseplan-assets/fr-BB7LeC_Q.js",
"houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js",
"houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js"
"houseplan-assets/de-Dc1N8boU.js",
"houseplan-assets/editor-DWFSXWYH.js",
"houseplan-assets/fr-Cw28JMxl.js",
"houseplan-assets/houseplan-editor-runtime-BQ2FUiZP.js",
"houseplan-assets/houseplan-onboarding-runtime-OhzyGbC7.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js",
"sha256": "924d285e33b3254880331d8f944030705b1ba87f920fe4ba1f50f1d60a98207c",
"rawBytes": 572891,
"gzipBytes": 149258,
"path": "houseplan-assets/houseplan-editor-runtime-BQ2FUiZP.js",
"sha256": "504fe0626f810cd221ba57a8b9807e0d4250da17545cd8ba0609d44bef967c88",
"rawBytes": 572919,
"gzipBytes": 149282,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js",
"sha256": "c59a3e4ee8c2b799568e3ed82b1f35526bf72b9e75e0f9769d033cb67aa7b716",
"path": "houseplan-assets/houseplan-onboarding-runtime-OhzyGbC7.js",
"sha256": "54105c7da5f96f26c7ee199726687ef046058bcb58f3ef27a2ff7b51e73c7415",
"rawBytes": 28088,
"gzipBytes": 6956,
"gzipBytes": 6955,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "f6e82e778343cf243de470eeafd5671d9d7746a730d385249fee0ca56d0a3b56",
"sha256": "ea553cfdc707a127f7098a3302bf8a6037ba8378b6afa8d8e3f0ea9601f19e41",
"rawBytes": 1183,
"gzipBytes": 796,
"gzipBytes": 799,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="91d90ad5fe8f4f44a45669312cea92fc5c5c061316d8382ea8ec58dbef1603d8";import{b as e,l as o,t,df as s,A as a,dg as i,dh as l,di as n,E as r,c}from"./houseplan-card-ruQDQ4Jw.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4386b4e9f688f89cce5a1680c9f21b315933dd897345287b8c64700400b11c11";import{b as e,l as o,t,df as s,A as a,dg as i,dh as l,di as n,E as r,c}from"./houseplan-card-qfwHeLJR.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="91d90ad5fe8f4f44a45669312cea92fc5c5c061316d8382ea8ec58dbef1603d8";try{await import("./houseplan-assets/houseplan-card-ruQDQ4Jw.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4386b4e9f688f89cce5a1680c9f21b315933dd897345287b8c64700400b11c11";try{await import("./houseplan-assets/houseplan-card-qfwHeLJR.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+1 -1
View File
@@ -189,7 +189,7 @@ const res = await page.evaluate(async () => {
c._decorTextDialog = { ...c._decorTextDialog, text: 'Default probe' };
c._decorSaveText();
c._furnPalette = { symbol: 'chair', w: 45, h: 45 };
c._furnPlace([gDefault * 24, gDefault * 12], true);
c._editorRuntime._furnPlace([gDefault * 24, gDefault * 12], true);
await c.updateComplete;
const createdByDefault = c._decorList.filter((shape) => shape.id !== existingDefaultProbe.id);
out.everyNewDecorKindUsesMainDefault = textInheritsDefault
+20 -1
View File
@@ -145,6 +145,17 @@ const result = await page.evaluate(async () => {
out.cancelledTouchDoesNotPlace = sp.decor.length === countBeforeTouch
&& c._furnTouchPending === null;
let committedPointerType = '';
const originalImagePlace = c._editorRuntime._decorImagePlace;
c._editorRuntime._decorImagePlace = (_raw, pointerType) => { committedPointerType = pointerType; };
c._decorTool = 'image';
c._decorImagePalette = asset;
pointer('pointerdown', 360, 360, { pointerType: 'touch', pointerId: 512 });
pointer('pointerup', 360, 360, { pointerType: 'touch', pointerId: 512 });
await settle();
c._editorRuntime._decorImagePlace = originalImagePlace;
out.touchCommitUsesTouchSnapTolerance = committedPointerType === 'touch';
c._decorTool = 'select';
const imageBeforeReplace = { ...sp.decor.find((shape) => shape.id === image.id) };
c._editorRuntime._openDecorProperties(imageBeforeReplace);
@@ -190,12 +201,16 @@ const result = await page.evaluate(async () => {
await settle();
await customElements.whenDefined('houseplan-space-card');
const config = JSON.parse(JSON.stringify(c._serverCfg));
let staticResolveCalls = 0;
const staticHass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return {
config, rev: 1, decor_assets_api: 1, virtual_lights: { rev: 0, config_rev: 1, off: [] },
};
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
if (message.type === 'houseplan/assets/resolve') return { assets: [asset, asset2], missing: [] };
if (message.type === 'houseplan/assets/resolve') {
staticResolveCalls++;
return { assets: [asset, asset2], missing: [] };
}
if (message.type === 'houseplan/content/sign') return { urls: Object.fromEntries(
message.paths.map((path) => [path, path === rawUrl ? url : path === rawUrl2 ? url2 : path]),
) };
@@ -227,6 +242,10 @@ const result = await page.evaluate(async () => {
&& viewBox.y + viewBox.height >= y + h;
out.staticImageIsInert = !!staticImage
&& getComputedStyle(staticImage).pointerEvents === 'none';
const resolveCallsAfterFirstLoad = staticResolveCalls;
await staticCard._load(true);
out.staticResolveCachesCompleteAssetSet = resolveCallsAfterFirstLoad === 1
&& staticResolveCalls === resolveCallsAfterFirstLoad;
host.remove();
sp.decor = saved.decor;
+57 -57
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "91d90ad5fe8f4f44a45669312cea92fc5c5c061316d8382ea8ec58dbef1603d8",
"fingerprint": "4386b4e9f688f89cce5a1680c9f21b315933dd897345287b8c64700400b11c11",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-ruQDQ4Jw.js",
"houseplan-assets/houseplan-card-qfwHeLJR.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291026,
"initialViewGzipBytes": 291041,
"lazyFiles": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/de-MNYUkgT1.js",
"houseplan-assets/editor-Ih3Tgb49.js",
"houseplan-assets/fr-BB7LeC_Q.js",
"houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js",
"houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/de-Dc1N8boU.js",
"houseplan-assets/editor-DWFSXWYH.js",
"houseplan-assets/fr-Cw28JMxl.js",
"houseplan-assets/houseplan-editor-runtime-BQ2FUiZP.js",
"houseplan-assets/houseplan-onboarding-runtime-OhzyGbC7.js"
],
"lazyGzipBytes": 213816,
"lazyGzipBytes": 213868,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/editor-Ih3Tgb49.js",
"houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/editor-DWFSXWYH.js",
"houseplan-assets/houseplan-editor-runtime-BQ2FUiZP.js"
],
"lazyEditorGzipBytes": 158012,
"lazyEditorGzipBytes": 158042,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/houseplan-onboarding-runtime-OhzyGbC7.js"
],
"lazyOnboardingGzipBytes": 14130,
"lazyOnboardingGzipBytes": 14131,
"lazyLocaleFiles": [
"houseplan-assets/de-MNYUkgT1.js",
"houseplan-assets/fr-BB7LeC_Q.js"
"houseplan-assets/de-Dc1N8boU.js",
"houseplan-assets/fr-Cw28JMxl.js"
],
"lazyLocaleGzipBytes": 48848,
"lazyLocaleGzipBytes": 48871,
"files": [
{
"path": "houseplan-assets/backdrop-pick-CsM7GqVe.js",
"sha256": "136838d9a113801d3d59b5198facdf568a90e9384f62133d84ee8ccfbcf91725",
"path": "houseplan-assets/backdrop-pick-Di7bhm6e.js",
"sha256": "a166e065bb3b3d4905c6e8e46c5c5157c32d03d58b8ece49caa661d78a4bf4a9",
"rawBytes": 20974,
"gzipBytes": 7174,
"gzipBytes": 7176,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-MNYUkgT1.js",
"sha256": "8414e28bb23e0cc2e8d777a97b217f8a971d002109ce38168c0890b0bd4a38ff",
"path": "houseplan-assets/de-Dc1N8boU.js",
"sha256": "d5707d18ebb3f3f423037557e1692cf038337ac60655fa6684f3b596afbce5bf",
"rawBytes": 81798,
"gzipBytes": 24696,
"gzipBytes": 24707,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Ih3Tgb49.js",
"sha256": "56f7be4294a42cb92bec171b3ea409425d70fb7ff5a4ca07ffae9190d9ea74d4",
"path": "houseplan-assets/editor-DWFSXWYH.js",
"sha256": "dcbb29ac4c28f8639044dee93688768b67d3044e6ec06dff59d028665a9e73e3",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BB7LeC_Q.js",
"sha256": "63b7104bab6abf083d5478736b8f5fe16ed7ca09edf52fa8cdeda418aa041bf8",
"path": "houseplan-assets/fr-Cw28JMxl.js",
"sha256": "8209da223a0b5fe0ea3681a1a8df7f6d2a28ea4d6376953b21be47a5b2257397",
"rawBytes": 83973,
"gzipBytes": 24152,
"gzipBytes": 24164,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-ruQDQ4Jw.js",
"sha256": "99538870102416fc6844acba2719f23a26f08d9f12fb2619d2788a83ee7d9a5b",
"rawBytes": 1033593,
"gzipBytes": 290230,
"path": "houseplan-assets/houseplan-card-qfwHeLJR.js",
"sha256": "2bd83b9350a1b4e702d28660cdc3b38a5daa3da16f9de47ddd5a839417443cbc",
"rawBytes": 1033605,
"gzipBytes": 290242,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-MNYUkgT1.js",
"houseplan-assets/editor-Ih3Tgb49.js",
"houseplan-assets/fr-BB7LeC_Q.js",
"houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js",
"houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js"
"houseplan-assets/de-Dc1N8boU.js",
"houseplan-assets/editor-DWFSXWYH.js",
"houseplan-assets/fr-Cw28JMxl.js",
"houseplan-assets/houseplan-editor-runtime-BQ2FUiZP.js",
"houseplan-assets/houseplan-onboarding-runtime-OhzyGbC7.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js",
"sha256": "924d285e33b3254880331d8f944030705b1ba87f920fe4ba1f50f1d60a98207c",
"rawBytes": 572891,
"gzipBytes": 149258,
"path": "houseplan-assets/houseplan-editor-runtime-BQ2FUiZP.js",
"sha256": "504fe0626f810cd221ba57a8b9807e0d4250da17545cd8ba0609d44bef967c88",
"rawBytes": 572919,
"gzipBytes": 149282,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js",
"sha256": "c59a3e4ee8c2b799568e3ed82b1f35526bf72b9e75e0f9769d033cb67aa7b716",
"path": "houseplan-assets/houseplan-onboarding-runtime-OhzyGbC7.js",
"sha256": "54105c7da5f96f26c7ee199726687ef046058bcb58f3ef27a2ff7b51e73c7415",
"rawBytes": 28088,
"gzipBytes": 6956,
"gzipBytes": 6955,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CsM7GqVe.js",
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/backdrop-pick-Di7bhm6e.js",
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "f6e82e778343cf243de470eeafd5671d9d7746a730d385249fee0ca56d0a3b56",
"sha256": "ea553cfdc707a127f7098a3302bf8a6037ba8378b6afa8d8e3f0ea9601f19e41",
"rawBytes": 1183,
"gzipBytes": 796,
"gzipBytes": 799,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-ruQDQ4Jw.js"
"houseplan-assets/houseplan-card-qfwHeLJR.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="91d90ad5fe8f4f44a45669312cea92fc5c5c061316d8382ea8ec58dbef1603d8";import{b as e,l as o,t,df as s,A as a,dg as i,dh as l,di as n,E as r,c}from"./houseplan-card-ruQDQ4Jw.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4386b4e9f688f89cce5a1680c9f21b315933dd897345287b8c64700400b11c11";import{b as e,l as o,t,df as s,A as a,dg as i,dh as l,di as n,E as r,c}from"./houseplan-card-qfwHeLJR.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="91d90ad5fe8f4f44a45669312cea92fc5c5c061316d8382ea8ec58dbef1603d8";try{await import("./houseplan-assets/houseplan-card-ruQDQ4Jw.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4386b4e9f688f89cce5a1680c9f21b315933dd897345287b8c64700400b11c11";try{await import("./houseplan-assets/houseplan-card-qfwHeLJR.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+36 -2
View File
@@ -1,4 +1,5 @@
import type { DecorShape } from './editors/decor/types';
import { clamp01, normalizeAngle } from './editors/decor/geometry';
export const DECOR_ASSETS_API_VERSION = 1;
export const DECOR_ASSET_ID_RE = /^[0-9a-f]{64}$/;
@@ -15,6 +16,12 @@ export interface DecorAsset {
used_by?: { space_id: string; decor_id: string }[];
}
export type DecorImageProjection = readonly [
x: number, y: number, w: number, h: number, opacity: number, transform: string,
];
const resolveCache = new WeakMap<object, [string, Map<string, DecorAsset>]>();
export function decorAssetIds(config: any): string[] {
const ids = new Set<string>();
for (const space of config?.spaces || []) {
@@ -25,6 +32,25 @@ export function decorAssetIds(config: any): string[] {
return [...ids];
}
/** One projection contract shared by the full and static renderers. */
export function projectDecorImage(
shape: Extract<DecorShape, { kind: 'image' }>,
canvasWidth: number,
canvasHeight: number,
): DecorImageProjection | null {
const x = Number(shape.x) * canvasWidth;
const y = Number(shape.y) * canvasHeight;
const w = Number(shape.w) * canvasWidth;
const h = Number(shape.h) * canvasHeight;
if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0) return null;
const cx = x + w / 2;
const cy = y + h / 2;
const angle = normalizeAngle(shape.angle);
const opacity = clamp01(shape.opacity, 1);
const transform = `translate(${cx} ${cy}) rotate(${angle}) scale(${shape.flip_h ? -1 : 1} ${shape.flip_v ? -1 : 1}) translate(${-cx} ${-cy})`;
return [x, y, w, h, opacity, transform];
}
export function adoptDecorAssets(value: unknown): Map<string, DecorAsset> {
const out = new Map<string, DecorAsset>();
const rows = (value as { assets?: unknown })?.assets;
@@ -47,10 +73,17 @@ export function adoptDecorAssets(value: unknown): Map<string, DecorAsset> {
/** Resolve every unique id without ever exceeding the backend message cap. */
export async function resolveDecorAssets(
hass: { callWS: (message: Record<string, unknown>) => Promise<unknown> },
hass: {
callWS: (message: Record<string, unknown>) => Promise<unknown>;
connection?: object;
},
assetIds: readonly string[],
): Promise<Map<string, DecorAsset>> {
const unique = [...new Set(assetIds.filter((id) => DECOR_ASSET_ID_RE.test(id)))];
const unique = [...new Set(assetIds.filter((id) => DECOR_ASSET_ID_RE.test(id)))].sort();
const owner = hass.connection || hass;
const key = unique.join(',');
const cached = resolveCache.get(owner);
if (cached?.[0] === key) return cached[1];
const out = new Map<string, DecorAsset>();
for (let offset = 0; offset < unique.length; offset += DECOR_ASSET_RESOLVE_BATCH) {
const response = await hass.callWS({
@@ -59,6 +92,7 @@ export async function resolveDecorAssets(
});
for (const [id, asset] of adoptDecorAssets(response)) out.set(id, asset);
}
resolveCache.set(owner, [key, out]);
return out;
}
+5 -5
View File
@@ -343,7 +343,7 @@ import {
} from './ha-binding-status';
import type { DecorShape, DecorStyle } from './editors/decor/types';
import {
DECOR_ASSETS_API_VERSION, decorAssetIds,
DECOR_ASSETS_API_VERSION, decorAssetIds, projectDecorImage,
resolveDecorAssets, type DecorAsset,
} from './decor-assets';
import {
@@ -8760,10 +8760,10 @@ export class HouseplanCard extends LitElement {
transform=${ang ? `rotate(${ang} ${cx} ${cy})` : nothing} @pointerdown=${down}></ellipse>` : nothing}`;
}
if (sh.kind === 'image') {
const projection = projectDecorImage(sh, W, H);
if (!projection) return nothing;
const [x, y, w, h, opacity, transform] = projection;
const asset = this._decorAssets.get(sh.asset_id);
const x = sh.x * W, y = sh.y * H, w = sh.w * W, h = sh.h * H;
const cx = x + w / 2, cy = y + h / 2;
const transform = `translate(${cx} ${cy}) rotate(${normalizeAngle(sh.angle)}) scale(${sh.flip_h ? -1 : 1} ${sh.flip_v ? -1 : 1}) translate(${-cx} ${-cy})`;
if (!asset) return editing && this._editorRuntime
? this._editorRuntime._renderMissingDecorImage(sh, cls, transform, x, y, w, h, down, dbl)
: nothing;
@@ -8771,7 +8771,7 @@ export class HouseplanCard extends LitElement {
if (!href) return nothing;
return svg`<image class="${cls} dimage" data-hp="decor" data-id=${sh.id}
data-kind="image" href=${href} x=${x} y=${y} width=${w} height=${h}
opacity=${clamp01(sh.opacity, 1)} preserveAspectRatio="none" transform=${transform}
opacity=${opacity} preserveAspectRatio="none" transform=${transform}
@load=${() => this._signer.markLoaded(this._renderPlanHass, asset.url, href)}
@pointerdown=${down} @dblclick=${dbl}></image>
${erasing ? svg`<rect class="dshape derasehit" data-hp="decor" data-id=${sh.id}
+4 -4
View File
@@ -4200,7 +4200,7 @@ public _decorPointerDown(ev: PointerEvent): boolean {
const pointerType = ev.pointerType || 'mouse';
if (pointerType === 'mouse') {
if (t === 'furniture') this._furnPlace(this._svgPoint(ev), ev.shiftKey, pointerType);
else this._decorImagePlace(this._svgPoint(ev));
else this._decorImagePlace(this._svgPoint(ev), pointerType);
return true;
}
const pending = this.host._furnTouchPending;
@@ -5042,7 +5042,7 @@ public _furnPointerUp(ev: PointerEvent): boolean {
|| (this.host._decorTool === 'image' && this.host._decorImagePalette))) {
if (this.host._decorTool === 'furniture')
this._furnPlace(this._svgPoint(ev), ev.shiftKey, pending.pointerType);
else this._decorImagePlace(this._svgPoint(ev));
else this._decorImagePlace(this._svgPoint(ev), pending.pointerType);
}
return true;
}
@@ -5127,11 +5127,11 @@ public _decorApplyBox(id: string, box: { x: number; y: number; w: number; h: num
this.host.requestUpdate();
}
public _decorImagePlace(raw: number[]): void {
public _decorImagePlace(raw: number[], pointerType = 'mouse'): void {
const asset = this.host._decorImagePalette;
const sp = this.host._curSpaceCfg;
if (!asset || !sp) return;
const at = this._decorSnap(raw);
const at = this._decorSnap(raw, pointerType);
const size = initialDecorImageCm(asset.width, asset.height);
const w = decorCmToUnits(size.w, this.host._cellCm, this.host._gridPitch) / NORM_W;
const h = decorCmToUnits(size.h, this.host._cellCm, this.host._gridPitch) / this.host._decorH;
+1 -1
View File
@@ -730,7 +730,7 @@ class HouseplanSpaceCard extends LitElement {
if (configChanged) this._continuity.note('config-candidate', { configRev: snap.rev });
try {
this._decorAssets = await resolveDecorAssets(this.hass, decorAssetIds(snap.config));
} catch { /* retain the last complete frame across a transient resolve failure */ }
} catch { /* retain the last complete frame; failed sets are not cached and retry next load */ }
if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: snap.layoutRev });
if (virtualLightsChanged) this._capturedSnapshotSequence = -1;
this._loadedOnce = true;
+5 -8
View File
@@ -57,6 +57,7 @@ import {
import { resolveZeroWalls } from './zero-walls';
import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { projectDecorImage } from './decor-assets';
import {
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
@@ -638,16 +639,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const raw = o.decorAssetUrl?.(String(shape.asset_id || '')) || '';
const href = raw && o.displayUrl ? o.displayUrl(raw) : raw;
if (!href) return [];
const x = Number(shape.x) * NORM_W, y = Number(shape.y) * NORM_W;
const w = Number(shape.w) * NORM_W, h = Number(shape.h) * NORM_W;
if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0) return [];
const cx = x + w / 2, cy = y + h / 2;
const angle = Number(shape.angle) || 0;
const transform = `translate(${cx} ${cy}) rotate(${angle}) scale(${shape.flip_h ? -1 : 1} ${shape.flip_v ? -1 : 1}) translate(${-cx} ${-cy})`;
const projection = projectDecorImage(shape, NORM_W, NORM_W);
if (!projection) return [];
const [x, y, w, h, opacity, transform] = projection;
return [svg`<image class="dimage" data-hp="decor"
href=${href} x=${x} y=${y} width=${w} height=${h}
opacity=${Number.isFinite(Number(shape.opacity))
? Math.max(0, Math.min(1, Number(shape.opacity))) : 1}
opacity=${opacity}
@load=${() => o.assetLoaded?.(raw, href)}
preserveAspectRatio="none" transform=${transform} pointer-events="none"></image>`];
});
+31 -1
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
adoptDecorAssets, decorAssetIds, initialDecorImageCm, resolveDecorAssets,
adoptDecorAssets, decorAssetIds, initialDecorImageCm,
projectDecorImage, resolveDecorAssets,
} from '../test-build/decor-assets.js';
import { decorBoxItem } from '../test-build/space-geometry.js';
@@ -20,6 +21,19 @@ test('#51 asset ids are unique across spaces and malformed ids fail closed', ()
] }), [id('b'), id('a')]);
});
test('#51 full and static renderers share one fail-closed image projection', () => {
const shape = {
id: 'image', kind: 'image', asset_id: id('d'),
x: 0.1, y: 0.2, w: 0.3, h: 0.4, angle: 405, opacity: 2,
flip_h: true,
};
assert.deepEqual(projectDecorImage(shape, 1000, 500), [
100, 100, 300, 200, 1,
'translate(250 200) rotate(45) scale(-1 1) translate(-250 -200)',
]);
assert.equal(projectDecorImage({ ...shape, w: 0 }, 1000, 500), null);
});
test('#51 resolve projection rejects malformed catalog rows', () => {
const good = {
asset_id: id('c'), name: 'safe.svg', mime: 'image/svg+xml',
@@ -54,4 +68,20 @@ test('#51 resolve batches and deduplicates ids at the backend cap', async () =>
const resolved = await resolveDecorAssets(hass, [ids[0], ...ids]);
assert.equal(resolved.size, 201);
assert.deepEqual(calls.map((batch) => batch.length), [200, 1]);
assert.equal(await resolveDecorAssets(hass, [...ids].reverse()), resolved);
assert.deepEqual(calls.map((batch) => batch.length), [200, 1]);
});
test('#51 missing asset ids are negative-cached with their complete set', async () => {
let calls = 0;
const connection = {};
const hass = {
connection,
callWS: async () => { calls++; return { assets: [], missing: [id('f')] }; },
};
const first = await resolveDecorAssets(hass, [id('f')]);
const second = await resolveDecorAssets({ ...hass }, [id('f')]);
assert.equal(first.size, 0);
assert.equal(second, first);
assert.equal(calls, 1);
});
+36
View File
@@ -53,6 +53,20 @@ def test_svg_rejects_the_whole_unsafe_document(payload: bytes) -> None:
validate_asset(payload, "unsafe.svg")
def test_svg_utf16_doctype_is_rejected_before_entity_expansion() -> None:
payload = '''<?xml version="1.0" encoding="UTF-16"?>
<!DOCTYPE svg [
<!ENTITY a "0123456789">
<!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">
<!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">
]>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
<path d="&c;"/>
</svg>'''.encode("utf-16")
with pytest.raises(DecorAssetError, match="DTD, entities"):
validate_asset(payload, "encoded.svg")
def test_svg_canonicalizes_safe_geometry_and_requires_aspect_ratio() -> None:
asset = validate_asset(
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20"><path d="M0 0L1 1"/></svg>',
@@ -92,6 +106,28 @@ def test_svg_resource_limits_fail_closed() -> None:
validate_asset(payload, "bounded.svg")
@pytest.mark.parametrize("attribute", [
'opacity="NaN"', 'opacity="1.1"', 'stop-opacity="101%"', 'offset="-0.1"',
])
def test_svg_rejects_non_finite_or_out_of_range_unit_values(attribute: str) -> None:
tag = "stop" if attribute.startswith(("stop", "offset")) else "path"
payload = (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">'
f'<{tag} {attribute}/></svg>'
).encode()
with pytest.raises(DecorAssetError, match="invalid|range"):
validate_asset(payload, "bounded.svg")
def test_svg_rejects_one_oversized_attribute_before_tree_use() -> None:
payload = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="'
+ b"M" * 65_537 + b'"/></svg>'
)
with pytest.raises(DecorAssetError, match="safety limit"):
validate_asset(payload, "bounded.svg")
def test_valid_looking_but_truncated_raster_is_rejected_by_full_decode() -> None:
with pytest.raises(DecorAssetError, match="corrupt|decode"):
validate_asset(PNG_1X1[:33], "truncated.png", "image/png")