From 3060fc48f3dee075a1562dcfca98944d4c2ba85e Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:11:51 +1030 Subject: [PATCH] fix(ids): decode XML entities on load and escape values on export --- .../src/openbim/IDSSpecifications/index.ts | 19 ++++--- .../IDSSpecifications/src/Specification.ts | 11 ++-- .../src/exporters/parameter.ts | 7 +-- .../IDSSpecifications/src/facets/Attribute.ts | 3 +- .../src/facets/Classification.ts | 3 +- .../IDSSpecifications/src/facets/Entity.ts | 3 +- .../IDSSpecifications/src/facets/Material.ts | 3 +- .../IDSSpecifications/src/facets/Property.ts | 18 +++++-- .../src/load-entities.test.ts | 54 +++++++++++++++++++ packages/core/src/utils/xml.ts | 13 +++++ 10 files changed, 110 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/openbim/IDSSpecifications/src/load-entities.test.ts diff --git a/packages/core/src/openbim/IDSSpecifications/index.ts b/packages/core/src/openbim/IDSSpecifications/index.ts index 82bb51249..d0e0619c2 100644 --- a/packages/core/src/openbim/IDSSpecifications/index.ts +++ b/packages/core/src/openbim/IDSSpecifications/index.ts @@ -20,6 +20,7 @@ import { import { ModelIdMap } from "../../fragments"; import { ModelIdMapUtils } from "../../utils"; // import { createPropertyFacets } from "./src/importers/property"; +import { XML } from "../../utils/xml"; /** * Component that manages Information Delivery Specification (IDS) data. It provides functionality for importing, exporting, and manipulating IDS data. 📕 [Tutorial](https://docs.thatopen.com/Tutorials/Components/Core/IDSSpecifications). 📘 [API](https://docs.thatopen.com/api/@thatopen/components/classes/IDSSpecifications). @@ -36,7 +37,9 @@ export class IDSSpecifications extends Component { numberParseOptions: { leadingZeros: true, hex: true }, parseAttributeValue: true, preserveOrder: false, - processEntities: false, + // Decode & < > " ' so values compare equal to the + // model's text. fast-xml-parser applies its expansion limits by default. + processEntities: true, removeNSPrefix: true, trimValues: true, }); @@ -240,14 +243,14 @@ export class IDSSpecifications extends Component { const xml = ` - ${info.title} - ${info.copyright ? `${info.copyright}` : ""} - ${info.version ? `${info.version}` : ""} - ${info.description ? `${info.description}` : ""} - ${info.author ? `${info.author}` : ""} + ${XML.escape(info.title)} + ${info.copyright ? `${XML.escape(info.copyright)}` : ""} + ${info.version ? `${XML.escape(info.version)}` : ""} + ${info.description ? `${XML.escape(info.description)}` : ""} + ${info.author ? `${XML.escape(info.author)}` : ""} ${info.date ? `${info.date.toISOString().split("T")[0]}` : ""} - ${info.purpose ? `${info.purpose}` : ""} - ${info.milestone ? `${info.milestone}` : ""} + ${info.purpose ? `${XML.escape(info.purpose)}` : ""} + ${info.milestone ? `${XML.escape(info.milestone)}` : ""} ${[..._specifications].map((spec) => spec.serialize()).join("\n")} diff --git a/packages/core/src/openbim/IDSSpecifications/src/Specification.ts b/packages/core/src/openbim/IDSSpecifications/src/Specification.ts index d3b692d48..96ce69af6 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/Specification.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/Specification.ts @@ -5,6 +5,7 @@ import { UUID } from "../../../utils"; import { IDSFacet } from "./facets"; import { IDSSpecifications } from ".."; import { ModelIdMap } from "../../../fragments"; +import { XML } from "../../../utils/xml"; /** * Represents a single specification from the Information Delivery Specification (IDS) standard. @@ -87,15 +88,17 @@ export class IDSSpecification implements IDSSpecificationData { * @returns The XML representation of the IDSSpecification. */ serialize() { - const name = `name="${this.name}"`; - const identifier = this.identifier ? `identifier="${this.identifier}"` : ""; + const name = `name="${XML.escape(this.name)}"`; + const identifier = this.identifier + ? `identifier="${XML.escape(this.identifier)}"` + : ""; const description = this.description - ? `description="${this.description}"` + ? `description="${XML.escape(this.description)}"` : ""; const instructions = this.instructions - ? `instructions="${this.instructions}"` + ? `instructions="${XML.escape(this.instructions)}"` : ""; const xml = ` diff --git a/packages/core/src/openbim/IDSSpecifications/src/exporters/parameter.ts b/packages/core/src/openbim/IDSSpecifications/src/exporters/parameter.ts index 7aee5b97e..af398d09c 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/exporters/parameter.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/exporters/parameter.ts @@ -1,4 +1,5 @@ import { IDSFacetParameterName, IDSFacetParameter } from "../types"; +import { XML } from "../../../../utils/xml"; export const getParameterXML = ( name: IDSFacetParameterName, @@ -8,20 +9,20 @@ export const getParameterXML = ( if (!parameter) return parameterXML; if (parameter.type === "simple") { - parameterXML = `${parameter.parameter}`; + parameterXML = `${XML.escape(parameter.parameter)}`; } if (parameter.type === "enumeration") { const value = parameter.parameter; parameterXML = ` - ${value.map((v) => ``).join("\n")} + ${value.map((v) => ``).join("\n")} `; } if (parameter.type === "pattern") { const value = parameter.parameter; parameterXML = ` - + `; } diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Attribute.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Attribute.ts index 162a3e9f2..841753f38 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Attribute.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Attribute.ts @@ -7,6 +7,7 @@ import { ModelIdDataMap, ModelIdMap, } from "../../../../fragments"; +import { XML } from "../../../../utils/xml"; // https://github.com/buildingSMART/IDS/blob/development/Documentation/UserManual/attribute-facet.md @@ -27,7 +28,7 @@ export class IDSAttribute extends IDSFacet { if (type === "requirement") { attributes += `cardinality="${this.cardinality}"`; attributes += this.instructions - ? `instructions="${this.instructions}"` + ? `instructions="${XML.escape(this.instructions)}"` : ""; } return ` diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Classification.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Classification.ts index 1690a9762..41bd2a715 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Classification.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Classification.ts @@ -3,6 +3,7 @@ import { IDSFacetParameter, IDSItemCheckResult } from "../types"; import { IDSFacet } from "./Facet"; import { getParameterXML } from "../exporters/parameter"; import { ModelIdDataMap, ModelIdMap } from "../../../../fragments"; +import { XML } from "../../../../utils/xml"; // https://github.com/buildingSMART/IDS/blob/development/Documentation/UserManual/classification-facet.md @@ -25,7 +26,7 @@ export class IDSClassification extends IDSFacet { attributes += `cardinality="${this.cardinality}"`; attributes += this.uri ? `uri=${this.uri}` : ""; attributes += this.instructions - ? `instructions="${this.instructions}"` + ? `instructions="${XML.escape(this.instructions)}"` : ""; } return ` diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts index 9e4f7293d..343c22413 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts @@ -9,6 +9,7 @@ import { ModelIdMap, } from "../../../../fragments"; import { ModelIdMapUtils } from "../../../../utils"; +import { XML } from "../../../../utils/xml"; // https://github.com/buildingSMART/IDS/blob/development/Documentation/UserManual/entity-facet.md @@ -29,7 +30,7 @@ export class IDSEntity extends IDSFacet { if (type === "requirement") { attributes += `cardinality="${this.cardinality}"`; attributes += this.instructions - ? `instructions="${this.instructions}"` + ? `instructions="${XML.escape(this.instructions)}"` : ""; } return ` diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Material.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Material.ts index 8bc440da6..4b95c9602 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Material.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Material.ts @@ -8,6 +8,7 @@ import { getParameterXML } from "../exporters/parameter"; import { IDSCheck, IDSFacetParameter, IDSItemCheckResult } from "../types"; import { IDSFacet } from "./Facet"; import { ModelIdMapUtils } from "../../../../utils"; +import { XML } from "../../../../utils/xml"; // https://github.com/buildingSMART/IDS/blob/development/Documentation/UserManual/material-facet.md @@ -59,7 +60,7 @@ export class IDSMaterial extends IDSFacet { attributes += `cardinality="${this.cardinality}"`; attributes += this.uri ? `uri=${this.uri}` : ""; attributes += this.instructions - ? `instructions="${this.instructions}"` + ? `instructions="${XML.escape(this.instructions)}"` : ""; } return ` diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts index 40f16f01b..cd0b2c48c 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts @@ -9,6 +9,7 @@ import { ModelIdMap, } from "../../../../fragments"; import { ModelIdMapUtils } from "../../../../utils"; +import { XML } from "../../../../utils/xml"; // https://github.com/buildingSMART/IDS/blob/development/Documentation/UserManual/property-facet.md @@ -46,7 +47,7 @@ export class IDSProperty extends IDSFacet { attributes += `cardinality="${this.cardinality}"`; attributes += this.uri ? `uri=${this.uri}` : ""; attributes += this.instructions - ? `instructions="${this.instructions}"` + ? `instructions="${XML.escape(this.instructions)}"` : ""; } return ` @@ -84,7 +85,10 @@ export class IDSProperty extends IDSFacet { // Matching sets, by localId: their name and the property names that // matched. A set attached to a type (IfcTypeObject.HasPropertySets) // has no DefinesOccurrence, so its elements are found from the type. - const matchedSets = new Map }>(); + const matchedSets = new Map< + number, + { name: string; props: Set } + >(); for (const set of data) { const definesOccurrence = set.DefinesOccurrence ?? set.DefinesOcurrence; @@ -220,7 +224,8 @@ export class IDSProperty extends IDSFacet { for (const type of types) { if (!Array.isArray(type.HasPropertySets)) continue; const sets = type.HasPropertySets.flatMap((set) => { - const id = set._localId && "value" in set._localId ? set._localId.value : null; + const id = + set._localId && "value" in set._localId ? set._localId.value : null; const matched = matchedSets.get(id as number); return matched ? [matched] : []; }); @@ -260,12 +265,15 @@ export class IDSProperty extends IDSFacet { const props = list ? set[list] : undefined; if (!Array.isArray(props)) return false; return props.some( - (prop) => prop.Name && "value" in prop.Name && prop.Name.value === propName, + (prop) => + prop.Name && "value" in prop.Name && prop.Name.value === propName, ); }); const inherits = inherited .get(id)! - .some((set) => [...set.props].some((prop) => !overrides(set.name, prop))); + .some((set) => + [...set.props].some((prop) => !overrides(set.name, prop)), + ); if (inherits) applies.push(id); } ModelIdMapUtils.append(collector, modelId, ...applies); diff --git a/packages/core/src/openbim/IDSSpecifications/src/load-entities.test.ts b/packages/core/src/openbim/IDSSpecifications/src/load-entities.test.ts new file mode 100644 index 000000000..a294efc23 --- /dev/null +++ b/packages/core/src/openbim/IDSSpecifications/src/load-entities.test.ts @@ -0,0 +1,54 @@ +import { afterAll, describe, expect, it } from "vitest"; +import type { Components } from "../../../index"; +import { loadSourceBundle } from "../../../../tests/source-bundle"; + +// Needs the real `Components`: see `loadSourceBundle`. +const OBC = await loadSourceBundle(); + +const ids = ` + + Entities + + + + IFCWALL + + + + Pset_R&D + Owner + Smith & Sons <Ltd> + + + + +`; + +describe("IDSSpecifications.load", () => { + const components: Components = new OBC.Components(); + afterAll(() => components.dispose()); + + it("decodes XML entities in names and facet values", () => { + const [spec] = components.get(OBC.IDSSpecifications).load(ids); + expect(spec.name).toBe("R&D walls"); + const [property] = [...spec.requirements] as InstanceType< + typeof OBC.IDSProperty + >[]; + expect(property.propertySet.parameter).toBe("Pset_R&D"); + expect(property.value?.parameter).toBe("Smith & Sons "); + }); + + it("round-trips them through export", () => { + const specs = components.get(OBC.IDSSpecifications); + specs.list.clear(); + specs.load(ids); + const xml = specs.export({ title: "Entities" }); + specs.list.clear(); + const [spec] = specs.load(xml); + expect(spec.name).toBe("R&D walls"); + const [property] = [...spec.requirements] as InstanceType< + typeof OBC.IDSProperty + >[]; + expect(property.value?.parameter).toBe("Smith & Sons "); + }); +}); diff --git a/packages/core/src/utils/xml.ts b/packages/core/src/utils/xml.ts index ae5328430..fb525b477 100644 --- a/packages/core/src/utils/xml.ts +++ b/packages/core/src/utils/xml.ts @@ -15,6 +15,19 @@ export class XML { trimValues: true, }); + /** + * Escapes a value for XML text or a double-quoted attribute, so a value + * holding `&`, `<`, `>` or quotes is written as data, not markup. + */ + static escape(value: unknown) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + static builder = new XMLBuilder({ attributeNamePrefix: "$", ignoreAttributes: false,