@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.
- package/dist/controller/fontReadiness.js +2 -0
- package/dist/docx/server/extractDocxText.d.ts +21 -2
- package/dist/docx/server/extractDocxText.js +130 -51
- package/dist/docx/xmlParser.d.ts +14 -1
- package/dist/docx/xmlParser.js +50 -3
- package/dist/layout-bridge/convert/toFlowBlocks.js +7 -0
- package/dist/layout-engine/measure/cache.js +1 -1
- package/dist/layout-engine/measure/measureContainer.js +25 -7
- package/dist/layout-engine/measure/measureHelpers.js +1 -0
- package/dist/layout-engine/measure/measureTypes.d.ts +5 -0
- package/dist/layout-engine/types.d.ts +9 -0
- package/dist/layout-painter/cursiveJoiners.d.ts +80 -0
- package/dist/layout-painter/cursiveJoiners.js +199 -0
- package/dist/layout-painter/renderPage.js +1 -0
- package/dist/layout-painter/renderParagraph.js +54 -11
- package/dist/server.d.ts +2 -2
- package/dist/utils/cursiveJoining.d.ts +49 -0
- package/dist/utils/cursiveJoining.js +97 -0
- package/dist/utils/joiningTypes.gen.d.ts +26 -0
- package/dist/utils/joiningTypes.gen.js +1136 -0
- package/dist/utils/scriptSegments.d.ts +36 -5
- package/dist/utils/scriptSegments.js +59 -8
- package/package.json +1 -1
|
@@ -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:
|
|
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,
|
|
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 =
|
|
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 =
|
|
52
|
+
const properties = findWordChild(paragraph, "pPr");
|
|
32
53
|
if (!properties) return {};
|
|
33
54
|
const result = {};
|
|
34
|
-
const
|
|
55
|
+
const style = findWordChild(properties, "pStyle");
|
|
56
|
+
const styleValue = getWordAttribute(style, "val");
|
|
35
57
|
if (styleValue !== null) result.style = styleValue;
|
|
36
|
-
const
|
|
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 (
|
|
44
|
-
const properties =
|
|
45
|
-
const boldProperty =
|
|
46
|
-
const boldValue =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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) =>
|
|
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 =
|
|
128
|
-
const gridSpanValue =
|
|
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 =
|
|
132
|
-
if (vMerge !== null &&
|
|
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:
|
|
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 =
|
|
217
|
+
const header = findWordChild(findWordChild(row, "trPr"), "tblHeader");
|
|
154
218
|
if (header === null) return false;
|
|
155
|
-
const value =
|
|
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
|
|
168
|
-
columns.push(
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
301
|
+
const extracted = readParagraph(paragraph);
|
|
302
|
+
const { text } = extracted;
|
|
214
303
|
const entry = {
|
|
215
304
|
index: startIndex + paragraphs.length,
|
|
216
|
-
|
|
217
|
-
|
|
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 =
|
|
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
|
-
|
|
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;
|
package/dist/docx/xmlParser.d.ts
CHANGED
|
@@ -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 };
|
package/dist/docx/xmlParser.js
CHANGED
|
@@ -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 = " ";
|
|
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 = " ";
|
|
|
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,
|
|
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 (
|
|
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
|
|
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;
|