@stll/folio-core 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -120,6 +120,8 @@ function addLayoutFontFamilyFace(faces, value, descriptor) {
120
120
  const fontFamily = value;
121
121
  addLayoutFontFamilyFace(faces, fontFamily.ascii, descriptor);
122
122
  addLayoutFontFamilyFace(faces, fontFamily.hAnsi, descriptor);
123
+ addLayoutFontFamilyFace(faces, fontFamily.cs, descriptor);
124
+ addLayoutFontFamilyFace(faces, fontFamily.eastAsia, descriptor);
123
125
  }
124
126
  function addLayoutFontFamilyNameFace(faces, family, descriptor) {
125
127
  const normalized = family.trim();
@@ -11,11 +11,30 @@ type DocxParagraphSource = "header" | "body" | "footer";
11
11
  * - `delimiter` — the `| --- |` line GFM requires under the header.
12
12
  */
13
13
  type DocxTableRowKind = "cells" | "syntheticHeader" | "delimiter";
14
+ /** Source paragraph inside a structured table cell. */
15
+ type ExtractedDocxTableCellParagraph = {
16
+ text: string;
17
+ style?: string;
18
+ bold?: boolean;
19
+ fontSize?: number;
20
+ alignment?: "left" | "center" | "right" | "both";
21
+ };
22
+ /** Source paragraphs contained by one physical table cell. */
23
+ type ExtractedDocxTableCell = {
24
+ /** Source `w:p` records in document order; empty padding cells have no entries. */
25
+ paragraphs: readonly ExtractedDocxTableCellParagraph[];
26
+ };
14
27
  /** Table membership of a paragraph whose `text` is a markdown table row. */
15
28
  type DocxTableRowPosition = {
16
29
  /** 0-based index of the source `w:tbl`, in extraction order across all parts. */
17
30
  table: number;
18
- kind: DocxTableRowKind;
31
+ kind: "cells";
32
+ /** Source cells aligned to the rendered GFM columns. */
33
+ cells: readonly ExtractedDocxTableCell[];
34
+ } | {
35
+ /** 0-based index of the source `w:tbl`, in extraction order across all parts. */
36
+ table: number;
37
+ kind: "syntheticHeader" | "delimiter";
19
38
  };
20
39
  /** Paragraph text and lightweight formatting metadata from a DOCX archive. */
21
40
  type ExtractedDocxParagraph = {
@@ -43,4 +62,4 @@ type ExtractedDocxText = {
43
62
  /** Extract paragraph text and formatting metadata from a DOCX archive. */
44
63
  declare const extractDocxText: (bytes: ArrayBuffer | Uint8Array) => Promise<ExtractedDocxText>;
45
64
  //#endregion
46
- export { DocxParagraphSource, DocxTableRowKind, DocxTableRowPosition, ExtractedDocxParagraph, ExtractedDocxText, extractDocxText };
65
+ export { DocxParagraphSource, DocxTableRowKind, DocxTableRowPosition, ExtractedDocxParagraph, ExtractedDocxTableCell, ExtractedDocxTableCellParagraph, ExtractedDocxText, extractDocxText };
@@ -1,14 +1,21 @@
1
1
  import { escapeTableCell } from "../../markdown/escape.js";
2
2
  import { RELATIONSHIP_TYPES, parseRelationships } from "../relsParser.js";
3
- import { findAllDeep, findChild, findDeep, getAttribute, getAttributeAnyPrefix, getLocalName, getTextContent, parseXml } from "../xmlParser.js";
3
+ import { findAllDeep, findDeep, getAttribute, getAttributeByNamespaceUri, getLocalName, getNamespaceUri, getTextContent, parseXml } from "../xmlParser.js";
4
4
  import { loadDocxArchive } from "./boundedArchive.js";
5
5
  //#region src/docx/server/extractDocxText.ts
6
6
  const DOCUMENT_RELS_PATH = "word/_rels/document.xml.rels";
7
+ const WORDPROCESSINGML_NAMESPACES = /* @__PURE__ */ new Set(["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]);
7
8
  const childElements = (element) => element.elements?.filter((child) => child.type === "element") ?? [];
9
+ const wordElementName = (element) => WORDPROCESSINGML_NAMESPACES.has(getNamespaceUri(element) ?? "") ? getLocalName(element.name) : null;
10
+ const findWordChild = (parent, localName) => {
11
+ if (!parent) return null;
12
+ return childElements(parent).find((child) => wordElementName(child) === localName) ?? null;
13
+ };
14
+ const getWordAttribute = (element, localName) => getAttributeByNamespaceUri(element, WORDPROCESSINGML_NAMESPACES, localName);
8
15
  const collectText = (element) => {
9
16
  let text = "";
10
17
  const walk = (node) => {
11
- const localName = getLocalName(node.name ?? "");
18
+ const localName = wordElementName(node);
12
19
  if (localName === "t") {
13
20
  text += getTextContent(node);
14
21
  return;
@@ -27,29 +34,45 @@ const collectText = (element) => {
27
34
  walk(element);
28
35
  return text;
29
36
  };
37
+ const countAcceptedTextChars = (element) => {
38
+ let chars = 0;
39
+ const walk = (node) => {
40
+ const localName = wordElementName(node);
41
+ if (localName === "t") {
42
+ chars += getTextContent(node).length;
43
+ return;
44
+ }
45
+ if (localName === "del" || localName === "delText" || localName === "moveFrom") return;
46
+ for (const child of childElements(node)) walk(child);
47
+ };
48
+ walk(element);
49
+ return chars;
50
+ };
30
51
  const readParagraphProperties = (paragraph) => {
31
- const properties = findChild(paragraph, "w", "pPr");
52
+ const properties = findWordChild(paragraph, "pPr");
32
53
  if (!properties) return {};
33
54
  const result = {};
34
- const styleValue = getAttributeAnyPrefix(findChild(properties, "w", "pStyle"), "val");
55
+ const style = findWordChild(properties, "pStyle");
56
+ const styleValue = getWordAttribute(style, "val");
35
57
  if (styleValue !== null) result.style = styleValue;
36
- const alignment = getAttributeAnyPrefix(findChild(properties, "w", "jc"), "val");
58
+ const justification = findWordChild(properties, "jc");
59
+ const alignment = getWordAttribute(justification, "val");
37
60
  if (alignment === "left" || alignment === "center" || alignment === "right" || alignment === "both") result.alignment = alignment;
38
61
  return result;
39
62
  };
40
63
  const readRunMetrics = (paragraph) => {
41
64
  const metrics = [];
42
65
  for (const run of childElements(paragraph)) {
43
- if (getLocalName(run.name ?? "") !== "r") continue;
44
- const properties = findChild(run, "w", "rPr");
45
- const boldProperty = findChild(properties, "w", "b");
46
- const boldValue = getAttributeAnyPrefix(boldProperty, "val");
66
+ if (wordElementName(run) !== "r") continue;
67
+ const properties = findWordChild(run, "rPr");
68
+ const boldProperty = findWordChild(properties, "b");
69
+ const boldValue = getWordAttribute(boldProperty, "val");
47
70
  const bold = boldProperty !== null && boldValue !== "0" && boldValue !== "false";
48
- const sizeValue = getAttributeAnyPrefix(findChild(properties, "w", "sz"), "val");
71
+ const sizeProperty = findWordChild(properties, "sz");
72
+ const sizeValue = getWordAttribute(sizeProperty, "val");
49
73
  const parsedSize = sizeValue === null ? NaN : Number.parseInt(sizeValue, 10);
50
74
  const fontSize = Number.isFinite(parsedSize) && parsedSize > 0 ? parsedSize : void 0;
51
- let chars = 0;
52
- for (const textNode of findAllDeep(run, "w", "t")) chars += getTextContent(textNode).length;
75
+ const chars = countAcceptedTextChars(run);
53
76
  if (chars === 0) continue;
54
77
  const entry = {
55
78
  bold,
@@ -60,6 +83,19 @@ const readRunMetrics = (paragraph) => {
60
83
  }
61
84
  return metrics;
62
85
  };
86
+ const readParagraph = (paragraph) => {
87
+ const entry = { text: collectText(paragraph) };
88
+ const { style, alignment } = readParagraphProperties(paragraph);
89
+ if (style !== void 0) entry.style = style;
90
+ if (alignment !== void 0) entry.alignment = alignment;
91
+ const runs = readRunMetrics(paragraph);
92
+ if (runs.length === 0) return entry;
93
+ const totalChars = runs.reduce((sum, run) => sum + run.chars, 0);
94
+ if (runs.reduce((sum, run) => sum + (run.bold ? run.chars : 0), 0) > totalChars / 2) entry.bold = true;
95
+ const firstFontSize = runs.find((run) => run.fontSize !== void 0)?.fontSize;
96
+ if (firstFontSize !== void 0) entry.fontSize = firstFontSize;
97
+ return entry;
98
+ };
63
99
  const TABLE_DELIMITER_CELL = "---";
64
100
  /** GFM cannot nest tables; an inner table joins its cells inside the outer cell. */
65
101
  const NESTED_TABLE_CELL_SEPARATOR = " / ";
@@ -77,7 +113,7 @@ const collectTableParts = (parent, localName) => {
77
113
  const parts = [];
78
114
  const walk = (node) => {
79
115
  for (const child of childElements(node)) {
80
- const childName = getLocalName(child.name ?? "");
116
+ const childName = wordElementName(child);
81
117
  if (childName === localName) {
82
118
  parts.push(child);
83
119
  continue;
@@ -94,47 +130,75 @@ const collectTableParts = (parent, localName) => {
94
130
  * Blank paragraphs are dropped so a cell padded with empty paragraphs does not
95
131
  * render as a run of `<br>`. A nested table contributes one line per inner row.
96
132
  */
97
- const readCellText = (cell, depth) => {
133
+ const readCellSourceParagraphs = (cell, depth) => {
134
+ const paragraphs = [];
135
+ const walk = (node) => {
136
+ for (const child of childElements(node)) {
137
+ const childName = wordElementName(child);
138
+ if (childName === "p") {
139
+ const paragraph = readParagraph(child);
140
+ if (paragraph.text.length > 0) paragraphs.push(paragraph);
141
+ continue;
142
+ }
143
+ if (childName === "tbl") {
144
+ if (depth >= MAX_NESTED_TABLE_DEPTH) continue;
145
+ for (const row of collectTableParts(child, "tr")) for (const nestedCell of collectTableParts(row, "tc")) for (const paragraph of readCellSourceParagraphs(nestedCell, depth + 1)) paragraphs.push(paragraph);
146
+ continue;
147
+ }
148
+ walk(child);
149
+ }
150
+ };
151
+ walk(cell);
152
+ return paragraphs;
153
+ };
154
+ const readCellRenderedLines = (cell, depth) => {
98
155
  const lines = [];
99
156
  const walk = (node) => {
100
157
  for (const child of childElements(node)) {
101
- const childName = getLocalName(child.name ?? "");
158
+ const childName = wordElementName(child);
102
159
  if (childName === "p") {
103
160
  const text = collectText(child);
104
161
  if (text.length > 0) lines.push(text);
105
162
  continue;
106
163
  }
107
164
  if (childName === "tbl") {
108
- if (depth >= MAX_NESTED_TABLE_DEPTH) continue;
109
- for (const line of flattenNestedTable(child, depth + 1)) lines.push(line);
165
+ if (depth < MAX_NESTED_TABLE_DEPTH) for (const line of flattenNestedTable(child, depth + 1)) lines.push(line);
110
166
  continue;
111
167
  }
112
168
  walk(child);
113
169
  }
114
170
  };
115
171
  walk(cell);
116
- return lines.join("\n");
172
+ return lines;
117
173
  };
118
174
  const flattenNestedTable = (table, depth) => {
119
175
  const lines = [];
120
176
  for (const row of collectTableParts(table, "tr")) {
121
- const cells = collectTableParts(row, "tc").map((cell) => readCellText(cell, depth));
177
+ const cells = collectTableParts(row, "tc").map((cell) => readCellRenderedLines(cell, depth).join("\n"));
122
178
  if (cells.some((text) => text.length > 0)) lines.push(cells.join(NESTED_TABLE_CELL_SEPARATOR));
123
179
  }
124
180
  return lines;
125
181
  };
182
+ const emptyTableCell = () => ({
183
+ text: "",
184
+ paragraphs: [],
185
+ gridSpan: 1
186
+ });
126
187
  const readTableCell = (cell, depth) => {
127
- const properties = findChild(cell, "w", "tcPr");
128
- const gridSpanValue = getAttributeAnyPrefix(findChild(properties, "w", "gridSpan"), "val");
188
+ const properties = findWordChild(cell, "tcPr");
189
+ const gridSpanValue = getWordAttribute(findWordChild(properties, "gridSpan"), "val");
129
190
  const parsedGridSpan = gridSpanValue === null ? 1 : Number.parseInt(gridSpanValue, 10);
130
191
  const gridSpan = Number.isFinite(parsedGridSpan) && parsedGridSpan > 1 ? parsedGridSpan : 1;
131
- const vMerge = findChild(properties, "w", "vMerge");
132
- if (vMerge !== null && getAttributeAnyPrefix(vMerge, "val") !== "restart") return {
192
+ const vMerge = findWordChild(properties, "vMerge");
193
+ if (vMerge !== null && getWordAttribute(vMerge, "val") !== "restart") return {
133
194
  text: "",
195
+ paragraphs: [],
134
196
  gridSpan
135
197
  };
198
+ const paragraphs = readCellSourceParagraphs(cell, depth);
136
199
  return {
137
- text: readCellText(cell, depth),
200
+ text: readCellRenderedLines(cell, depth).join("\n"),
201
+ paragraphs,
138
202
  gridSpan
139
203
  };
140
204
  };
@@ -150,11 +214,17 @@ const readTableCell = (cell, depth) => {
150
214
  * names invented here would be read back as facts about the document.
151
215
  */
152
216
  const declaresHeaderRow = (row) => {
153
- const header = findChild(findChild(row, "w", "trPr"), "w", "tblHeader");
217
+ const header = findWordChild(findWordChild(row, "trPr"), "tblHeader");
154
218
  if (header === null) return false;
155
- const value = getAttributeAnyPrefix(header, "val");
219
+ const value = getWordAttribute(header, "val");
156
220
  return value !== "0" && value !== "false";
157
221
  };
222
+ const readRowGridOffset = (row, localName) => {
223
+ const properties = findWordChild(row, "trPr");
224
+ const value = getWordAttribute(findWordChild(properties, localName), "val");
225
+ const parsed = value === null ? 0 : Number.parseInt(value, 10);
226
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, MAX_TABLE_COLUMNS) : 0;
227
+ };
158
228
  const readTableGrid = (table) => {
159
229
  const rows = [];
160
230
  let columnCount = 0;
@@ -162,13 +232,17 @@ const readTableGrid = (table) => {
162
232
  for (const [rowIndex, row] of collectTableParts(table, "tr").entries()) {
163
233
  if (rowIndex === 0) firstRowIsHeader = declaresHeaderRow(row);
164
234
  const columns = [];
235
+ const gridBefore = readRowGridOffset(row, "gridBefore");
236
+ for (let index = 0; index < gridBefore; index += 1) columns.push(emptyTableCell());
165
237
  for (const cell of collectTableParts(row, "tc")) {
166
238
  if (columns.length >= MAX_TABLE_COLUMNS) break;
167
- const { text, gridSpan } = readTableCell(cell, 0);
168
- columns.push(text);
169
- const padding = Math.min(gridSpan - 1, MAX_TABLE_COLUMNS - columns.length);
170
- for (let index = 0; index < padding; index++) columns.push("");
239
+ const extractedCell = readTableCell(cell, 0);
240
+ columns.push(extractedCell);
241
+ const padding = Math.min(extractedCell.gridSpan - 1, MAX_TABLE_COLUMNS - columns.length);
242
+ for (let index = 0; index < padding; index++) columns.push(emptyTableCell());
171
243
  }
244
+ const gridAfter = Math.min(readRowGridOffset(row, "gridAfter"), MAX_TABLE_COLUMNS - columns.length);
245
+ for (let index = 0; index < gridAfter; index += 1) columns.push(emptyTableCell());
172
246
  if (columns.length > columnCount) columnCount = columns.length;
173
247
  rows.push(columns);
174
248
  }
@@ -181,7 +255,7 @@ const readTableGrid = (table) => {
181
255
  /** Pad a row to the table's column count and escape each cell into a pipe row. */
182
256
  const toRowLine = (columns, columnCount) => {
183
257
  const cells = [];
184
- for (let column = 0; column < columnCount; column++) cells.push(escapeTableCell(columns[column] ?? ""));
258
+ for (let column = 0; column < columnCount; column++) cells.push(escapeTableCell(columns[column]?.text ?? ""));
185
259
  return `| ${cells.join(" | ")} |`;
186
260
  };
187
261
  /** Render a `w:tbl` as GFM rows. A table with no cell at all renders nothing. */
@@ -190,7 +264,7 @@ const renderTableRows = (table, tableIndex) => {
190
264
  const [firstRow, ...remainingRows] = rows;
191
265
  if (columnCount === 0 || firstRow === void 0) return [];
192
266
  const rendered = [];
193
- const push = (text, kind) => {
267
+ const pushScaffolding = (text, kind) => {
194
268
  rendered.push({
195
269
  text,
196
270
  position: {
@@ -199,10 +273,24 @@ const renderTableRows = (table, tableIndex) => {
199
273
  }
200
274
  });
201
275
  };
202
- if (firstRowIsHeader) push(toRowLine(firstRow, columnCount), "cells");
203
- else push(toRowLine([], columnCount), "syntheticHeader");
204
- push(toRowLine(Array.from({ length: columnCount }, () => TABLE_DELIMITER_CELL), columnCount), "delimiter");
205
- for (const row of firstRowIsHeader ? remainingRows : rows) push(toRowLine(row, columnCount), "cells");
276
+ const pushCells = (cells) => {
277
+ rendered.push({
278
+ text: toRowLine(cells, columnCount),
279
+ position: {
280
+ table: tableIndex,
281
+ kind: "cells",
282
+ cells: Array.from({ length: columnCount }, (_, column) => ({ paragraphs: cells.at(column)?.paragraphs ?? [] }))
283
+ }
284
+ });
285
+ };
286
+ if (firstRowIsHeader) pushCells(firstRow);
287
+ else pushScaffolding(toRowLine([], columnCount), "syntheticHeader");
288
+ pushScaffolding(toRowLine(Array.from({ length: columnCount }, () => ({
289
+ text: TABLE_DELIMITER_CELL,
290
+ paragraphs: [],
291
+ gridSpan: 1
292
+ })), columnCount), "delimiter");
293
+ for (const row of firstRowIsHeader ? remainingRows : rows) pushCells(row);
206
294
  return rendered;
207
295
  };
208
296
  const extractContainer = ({ container, source, startIndex, startTableIndex }) => {
@@ -210,22 +298,13 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
210
298
  let charCount = 0;
211
299
  let tableCount = 0;
212
300
  const pushProse = (paragraph) => {
213
- const text = collectText(paragraph);
301
+ const extracted = readParagraph(paragraph);
302
+ const { text } = extracted;
214
303
  const entry = {
215
304
  index: startIndex + paragraphs.length,
216
- text,
217
- source
305
+ source,
306
+ ...extracted
218
307
  };
219
- const { style, alignment } = readParagraphProperties(paragraph);
220
- if (style !== void 0) entry.style = style;
221
- if (alignment !== void 0) entry.alignment = alignment;
222
- const runs = readRunMetrics(paragraph);
223
- if (runs.length > 0) {
224
- const totalChars = runs.reduce((sum, run) => sum + run.chars, 0);
225
- if (runs.reduce((sum, run) => sum + (run.bold ? run.chars : 0), 0) > totalChars / 2) entry.bold = true;
226
- const firstFontSize = runs.find((run) => run.fontSize !== void 0)?.fontSize;
227
- if (firstFontSize !== void 0) entry.fontSize = firstFontSize;
228
- }
229
308
  paragraphs.push(entry);
230
309
  charCount += text.length;
231
310
  };
@@ -246,7 +325,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
246
325
  */
247
326
  const walkBlocks = (node) => {
248
327
  for (const child of childElements(node)) {
249
- const childName = getLocalName(child.name ?? "");
328
+ const childName = wordElementName(child);
250
329
  if (childName === "tbl") {
251
330
  for (const row of renderTableRows(child, startTableIndex + tableCount)) pushTableRow(row);
252
331
  tableCount += 1;
@@ -279,7 +358,7 @@ const extractParts = async ({ archive, source, rootName, startIndex, startTableI
279
358
  startIndex: nextIndex,
280
359
  startTableIndex: startTableIndex + tableCount
281
360
  });
282
- paragraphs.push(...result.paragraphs);
361
+ for (const paragraph of result.paragraphs) paragraphs.push(paragraph);
283
362
  charCount += result.charCount;
284
363
  tableCount += result.tableCount;
285
364
  nextIndex += result.paragraphs.length;
@@ -36,8 +36,17 @@ type XmlElement = {
36
36
  text?: string | number | boolean;
37
37
  type?: string;
38
38
  name?: string;
39
+ /** Resolved namespace URI; non-enumerable so legacy structural comparisons stay stable. */
40
+ namespaceUri?: string;
41
+ /** In-scope namespace declarations; non-enumerable so legacy comparisons stay stable. */
42
+ namespaceScope?: XmlNamespaceScope;
39
43
  elements?: XmlElement[];
40
44
  };
45
+ /** Parent-linked XML namespace declarations; each scope stores only local overrides. */
46
+ type XmlNamespaceScope = {
47
+ bindings: ReadonlyMap<string, string>;
48
+ parent?: XmlNamespaceScope;
49
+ };
41
50
  /**
42
51
  * Common OOXML namespace URIs — re-exported from @stll/docx-utils.
43
52
  */
@@ -85,6 +94,10 @@ declare function getLocalName(name: string | undefined): string;
85
94
  * e.g., "w:p" -> "w", "a:graphic" -> "a"
86
95
  */
87
96
  declare function getNamespacePrefix(name: string): string | null;
97
+ /** Namespace URI resolved from the element's in-scope XML declarations. */
98
+ declare const getNamespaceUri: (element: XmlElement) => string | undefined;
99
+ /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
100
+ declare function getAttributeByNamespaceUri(element: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): string | null;
88
101
  /**
89
102
  * Check if an element matches a given namespaced name
90
103
  *
@@ -313,4 +326,4 @@ declare function mergeXmlnsDeclarations(inherited: Record<string, string>, eleme
313
326
  */
314
327
  declare function cloneWithXmlnsDeclarations(element: XmlElement, xmlnsDecls: Record<string, string>): XmlElement;
315
328
  //#endregion
316
- export { NAMESPACES, XmlElement, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
329
+ export { NAMESPACES, XmlElement, XmlNamespaceScope, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -55,6 +55,15 @@ const TEXT_KEY = "#text";
55
55
  const ATTR_KEY = ":@";
56
56
  /** Character reference required to keep carriage returns through XML end-of-line normalization. */
57
57
  const XML_CARRIAGE_RETURN_REFERENCE = "&#13;";
58
+ const EMPTY_NAMESPACE_SCOPE = { bindings: /* @__PURE__ */ new Map() };
59
+ const resolveNamespaceUri = (scope, prefix) => {
60
+ let current = scope;
61
+ while (current) {
62
+ const value = current.bindings.get(prefix);
63
+ if (value !== void 0) return value;
64
+ current = current.parent;
65
+ }
66
+ };
58
67
  /**
59
68
  * Convert a fast-xml-parser preserveOrder node into an XmlElement.
60
69
  *
@@ -62,7 +71,7 @@ const XML_CARRIAGE_RETURN_REFERENCE = "&#13;";
62
71
  * (the tag name or `#text`) whose value is the children array, plus an
63
72
  * optional `:@` key holding the attributes object.
64
73
  */
65
- function fxpNodeToElement(node) {
74
+ function fxpNodeToElement(node, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
66
75
  if (TEXT_KEY in node) return {
67
76
  type: "text",
68
77
  text: node[TEXT_KEY]
@@ -76,8 +85,34 @@ function fxpNodeToElement(node) {
76
85
  name: key
77
86
  };
78
87
  if (attrs) element.attributes = attrs;
88
+ let localBindings = null;
89
+ if (attrs) for (const [attribute, value] of Object.entries(attrs)) {
90
+ if (attribute !== "xmlns" && !attribute.startsWith("xmlns:")) continue;
91
+ localBindings ??= /* @__PURE__ */ new Map();
92
+ const prefix = attribute === "xmlns" ? "" : attribute.slice(6);
93
+ localBindings.set(prefix, value);
94
+ }
95
+ const namespaceScope = localBindings === null ? inheritedNamespaceScope : {
96
+ bindings: localBindings,
97
+ parent: inheritedNamespaceScope
98
+ };
99
+ Object.defineProperty(element, "namespaceScope", {
100
+ configurable: false,
101
+ enumerable: false,
102
+ value: namespaceScope,
103
+ writable: false
104
+ });
105
+ const colonIndex = key.indexOf(":");
106
+ const prefix = colonIndex === -1 ? "" : key.slice(0, colonIndex);
107
+ const namespaceUri = resolveNamespaceUri(namespaceScope, prefix);
108
+ if (namespaceUri !== void 0) Object.defineProperty(element, "namespaceUri", {
109
+ configurable: false,
110
+ enumerable: false,
111
+ value: namespaceUri,
112
+ writable: false
113
+ });
79
114
  if (children.length > 0) {
80
- for (let index = 0; index < children.length; index += 1) children[index] = fxpNodeToElement(children[index]);
115
+ for (let index = 0; index < children.length; index += 1) children[index] = fxpNodeToElement(children[index], namespaceScope);
81
116
  element.elements = children;
82
117
  }
83
118
  return element;
@@ -159,6 +194,18 @@ function getNamespacePrefix(name) {
159
194
  const colonIndex = name.indexOf(":");
160
195
  return colonIndex !== -1 ? name.slice(0, colonIndex) : null;
161
196
  }
197
+ /** Namespace URI resolved from the element's in-scope XML declarations. */
198
+ const getNamespaceUri = (element) => element.namespaceUri;
199
+ /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
200
+ function getAttributeByNamespaceUri(element, namespaceUris, localName) {
201
+ if (!element?.attributes) return null;
202
+ for (const [name, value] of Object.entries(element.attributes)) {
203
+ if (value === void 0 || getLocalName(name) !== localName) continue;
204
+ const prefix = getNamespacePrefix(name);
205
+ if (prefix !== null && namespaceUris.has(resolveNamespaceUri(element.namespaceScope, prefix) ?? "")) return String(value);
206
+ }
207
+ return null;
208
+ }
162
209
  function hasLocalName(name, localName) {
163
210
  if (!name) return false;
164
211
  if (name === localName) return true;
@@ -640,4 +687,4 @@ function cloneWithXmlnsDeclarations(element, xmlnsDecls) {
640
687
  return element;
641
688
  }
642
689
  //#endregion
643
- export { NAMESPACES, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
690
+ export { NAMESPACES, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -246,6 +246,8 @@ function extractRunFormatting(marks, theme) {
246
246
  if (font) formatting.fontFamily = font;
247
247
  const eastAsiaFont = resolveEastAsiaThemeFont(attrs, theme);
248
248
  if (eastAsiaFont) formatting.eastAsiaFontFamily = eastAsiaFont;
249
+ const complexScriptFont = resolveComplexScriptThemeFont(attrs, theme);
250
+ if (complexScriptFont) formatting.complexScriptFontFamily = complexScriptFont;
249
251
  break;
250
252
  }
251
253
  case "language": {
@@ -361,6 +363,9 @@ const resolveWesternThemeFont = (fontFamily, theme) => {
361
363
  const themeRef = fontFamily.asciiTheme ?? fontFamily.hAnsiTheme;
362
364
  return (themeRef ? resolveThemeFont(themeRef, theme?.fontScheme) : null) ?? fontFamily.ascii ?? fontFamily.hAnsi ?? void 0;
363
365
  };
366
+ const resolveComplexScriptThemeFont = (fontFamily, theme) => {
367
+ return (fontFamily.cstheme ? resolveThemeFont(fontFamily.cstheme, theme?.fontScheme) : null) ?? fontFamily.cs ?? void 0;
368
+ };
364
369
  const resolveEastAsiaThemeFont = (fontFamily, theme) => {
365
370
  return (fontFamily.eastAsiaTheme ? resolveThemeFont(fontFamily.eastAsiaTheme, theme?.fontScheme) : null) ?? fontFamily.eastAsia ?? void 0;
366
371
  };
@@ -404,6 +409,8 @@ function paragraphRunDefaults(pmAttrs, theme) {
404
409
  if (fontFamily) result.fontFamily = fontFamily;
405
410
  const eastAsiaFontFamily = defaultTextFormatting.fontFamily ? resolveEastAsiaThemeFont(defaultTextFormatting.fontFamily, theme) : void 0;
406
411
  if (eastAsiaFontFamily) result.eastAsiaFontFamily = eastAsiaFontFamily;
412
+ const complexScriptFontFamily = defaultTextFormatting.fontFamily ? resolveComplexScriptThemeFont(defaultTextFormatting.fontFamily, theme) : void 0;
413
+ if (complexScriptFontFamily) result.complexScriptFontFamily = complexScriptFontFamily;
407
414
  if (defaultTextFormatting.language) result.language = { ...defaultTextFormatting.language };
408
415
  if (defaultTextFormatting.fontSize !== void 0) result.fontSize = defaultTextFormatting.fontSize / 2;
409
416
  if (defaultTextFormatting.bold !== void 0) result.bold = defaultTextFormatting.bold;
@@ -170,7 +170,7 @@ const paragraphMeasureCache = /* @__PURE__ */ new Map();
170
170
  */
171
171
  function hashParagraphBlock(block) {
172
172
  const parts = [`lbp:${getLineBreakProviderGeneration()}`];
173
- for (const run of block.runs) if (run.kind === "text") parts.push(`t:${run.text}|${run.fontFamily}|${run.eastAsiaFontFamily}|${run.fontSize}|${run.bold}|${run.italic}|${run.allCaps}|${run.smallCaps}|${run.horizontalScale}|${run.letterSpacing}|${run.language?.val}|${run.language?.eastAsia}|${run.language?.bidi}`);
173
+ for (const run of block.runs) if (run.kind === "text") parts.push(`t:${run.text}|${run.fontFamily}|${run.eastAsiaFontFamily}|${run.complexScriptFontFamily}|${run.fontSize}|${run.bold}|${run.italic}|${run.allCaps}|${run.smallCaps}|${run.horizontalScale}|${run.letterSpacing}|${run.language?.val}|${run.language?.eastAsia}|${run.language?.bidi}`);
174
174
  else if (run.kind === "tab") parts.push(`tab:${run.width}`);
175
175
  else if (run.kind === "image") parts.push(`img:${run.width}x${run.height}:${run.exactLineHeight === true ? "exact" : "text"}`);
176
176
  else if (run.kind === "lineBreak") parts.push("br");
@@ -1,4 +1,4 @@
1
- import { hasCjk, isCjkCodePoint, segmentByScript } from "../../utils/scriptSegments.js";
1
+ import { SCRIPT_CLASS, hasCjk, hasComplexScript, scriptClassOf, segmentByScript } from "../../utils/scriptSegments.js";
2
2
  import { getCachedFontMetrics, getCachedTextWidth, getTextWidthCacheGeneration, setCachedFontMetrics, setCachedTextWidth } from "./cache.js";
3
3
  import { buildFontString, getResolvedData, ptToPx } from "./measureHelpers.js";
4
4
  import { setMeasureProvider } from "./measureProvider.js";
@@ -118,7 +118,7 @@ function canvasGetFontMetrics(style) {
118
118
  function canvasMeasureTextWidth(text, style) {
119
119
  if (!text) return 0;
120
120
  const measuredText = applyTextTransform(text, style);
121
- if (style.eastAsiaFontFamily && !style.letterSpacing && hasCjk(measuredText)) return measureMixedScriptWidth(measuredText, style);
121
+ if (!style.letterSpacing && needsPerScriptFonts(style, measuredText)) return measureMixedScriptWidth(measuredText, style);
122
122
  const ctx = getCanvasContext();
123
123
  const font = buildFontString(style);
124
124
  const letterSpacing = style.letterSpacing ?? 0;
@@ -173,14 +173,25 @@ function glyphAdvanceStyle(style, fontFamily) {
173
173
  * horizontal scale are applied once over the whole string so the total matches
174
174
  * the painter, which renders the same segments as sibling spans.
175
175
  */
176
+ /**
177
+ * Whether this run needs more than one font, i.e. it carries a per-script slot
178
+ * AND text that would select it. Keeps the all-Latin path on a single font.
179
+ */
180
+ function needsPerScriptFonts(style, measuredText) {
181
+ if (style.eastAsiaFontFamily && hasCjk(measuredText)) return true;
182
+ return Boolean(style.complexScriptFontFamily) && hasComplexScript(measuredText);
183
+ }
184
+ /** The font slot a script class selects, falling back to the western one. */
185
+ function scriptFontFamily(style, script) {
186
+ if (script === SCRIPT_CLASS.eastAsia) return style.eastAsiaFontFamily ?? style.fontFamily;
187
+ if (script === SCRIPT_CLASS.complex) return style.complexScriptFontFamily ?? style.fontFamily;
188
+ return style.fontFamily;
189
+ }
176
190
  function measureMixedScriptWidth(measuredText, style) {
177
191
  const letterSpacing = style.letterSpacing ?? 0;
178
192
  const horizontalScale = getHorizontalScaleFactor(style);
179
193
  let glyphWidth = 0;
180
- for (const segment of segmentByScript(measuredText)) {
181
- const fontFamily = segment.isCjk ? style.eastAsiaFontFamily : style.fontFamily;
182
- glyphWidth += canvasMeasureTextWidth(segment.text, glyphAdvanceStyle(style, fontFamily));
183
- }
194
+ for (const segment of segmentByScript(measuredText)) glyphWidth += canvasMeasureTextWidth(segment.text, glyphAdvanceStyle(style, scriptFontFamily(style, segment.script)));
184
195
  let width = glyphWidth;
185
196
  if (letterSpacing) {
186
197
  const codePoints = countCodePoints(measuredText);
@@ -251,6 +262,10 @@ function canvasMeasureRun(text, style) {
251
262
  ...style,
252
263
  fontFamily: style.eastAsiaFontFamily
253
264
  }) : void 0;
265
+ const complexScriptFont = style.complexScriptFontFamily !== void 0 && !style.letterSpacing ? buildFontString({
266
+ ...style,
267
+ fontFamily: style.complexScriptFontFamily
268
+ }) : void 0;
254
269
  const letterSpacing = style.letterSpacing ?? 0;
255
270
  const scale = getHorizontalScaleFactor(style);
256
271
  const charWidths = [];
@@ -259,7 +274,10 @@ function canvasMeasureRun(text, style) {
259
274
  for (const char of text) {
260
275
  const cp = char.codePointAt(0);
261
276
  const measured = applyTextTransform(char, style);
262
- if (eastAsiaFont !== void 0) ctx.font = isCjkCodePoint(cp) ? eastAsiaFont : baseFont;
277
+ if (eastAsiaFont !== void 0 || complexScriptFont !== void 0) {
278
+ const script = scriptClassOf(cp);
279
+ ctx.font = (script === SCRIPT_CLASS.eastAsia ? eastAsiaFont : void 0) ?? (script === SCRIPT_CLASS.complex ? complexScriptFont : void 0) ?? baseFont;
280
+ }
263
281
  let charWidth = ctx.measureText(measured).width;
264
282
  if (letterSpacing && offset + char.length < text.length) charWidth += letterSpacing;
265
283
  charWidth *= scale;
@@ -28,6 +28,7 @@ function buildRunFontStyle(run, fallbackFontFamily, fallbackFontSize) {
28
28
  return {
29
29
  fontFamily: run.fontFamily ?? fallbackFontFamily,
30
30
  ...run.eastAsiaFontFamily !== void 0 ? { eastAsiaFontFamily: run.eastAsiaFontFamily } : {},
31
+ ...run.complexScriptFontFamily !== void 0 ? { complexScriptFontFamily: run.complexScriptFontFamily } : {},
31
32
  fontSize,
32
33
  ...run.bold !== void 0 ? { bold: run.bold } : {},
33
34
  ...run.italic !== void 0 ? { italic: run.italic } : {},
@@ -21,6 +21,11 @@ type FontStyle = {
21
21
  * and click positioning stay in sync.
22
22
  */
23
23
  eastAsiaFontFamily?: string;
24
+ /**
25
+ * Complex-script font for Arabic, Hebrew, Indic and South-East Asian code
26
+ * points. Same contract as `eastAsiaFontFamily`, over a different slot.
27
+ */
28
+ complexScriptFontFamily?: string;
24
29
  fontSize?: number;
25
30
  bold?: boolean;
26
31
  italic?: boolean;
@@ -37,6 +37,15 @@ type RunFormatting = {
37
37
  * to `fontFamily`.
38
38
  */
39
39
  eastAsiaFontFamily?: string;
40
+ /**
41
+ * Resolved complex-script font (`w:cs` / `cstheme`). Arabic, Hebrew, Indic
42
+ * and South-East Asian code points in this run measure and paint with this
43
+ * font; the rest keeps `fontFamily` (ascii/hAnsi). Mirrors
44
+ * `eastAsiaFontFamily` exactly, including the shared segmentation. Absent
45
+ * means complex-script text falls back to `fontFamily`, which is what Word
46
+ * does NOT do and is why this slot has to reach layout at all.
47
+ */
48
+ complexScriptFontFamily?: string;
40
49
  /** Run language metadata resolved from `w:lang`. */
41
50
  language?: {
42
51
  val?: string;