@stll/folio-core 0.15.13 → 0.17.0

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.
@@ -8,13 +8,15 @@ import { attemptSelectiveSave } from "../docx/selectiveSave.js";
8
8
  import { acceptAIEditRevision, acceptAllChanges, rejectAIEditRevision, rejectAllChanges } from "../prosemirror/commands/comments.js";
9
9
  import { proseDocToBlocks, updateDocumentContent } from "../prosemirror/conversion/fromProseDoc.js";
10
10
  import { footnoteToProseDoc, headerFooterToProseDoc, toProseDoc } from "../prosemirror/conversion/toProseDoc.js";
11
- import { getChangedParagraphIds, hasStructuralChanges, hasUntrackedChanges, ignoreTrackedChanges } from "../prosemirror/extensions/features/ParagraphChangeTrackerExtension.js";
11
+ import { ensureBaseDirectionInState } from "../prosemirror/extensions/features/AutoBidiDetectionExtension.js";
12
+ import { getChangedParagraphIds, hasStructuralChanges, hasUntrackedChanges } from "../prosemirror/extensions/features/ParagraphChangeTrackerExtension.js";
12
13
  import { schema, singletonManager } from "../prosemirror/schema/index.js";
13
14
  import { deterministicHexId } from "../utils/hexId.js";
14
15
  import { buildAnnotatedBlockText } from "./clean-text.js";
15
16
  import { getCommentAnchorsFromDoc, getTrackedChangesFromDoc } from "./read.js";
16
17
  import { createFolioAIEditSnapshot, normalizeFolioAIBlockText } from "./snapshot.js";
17
18
  import { TaggedError } from "better-result";
19
+ import { Fragment } from "prosemirror-model";
18
20
  import { EditorState } from "prosemirror-state";
19
21
  //#region src/ai-edits/headless.ts
20
22
  /**
@@ -80,39 +82,48 @@ const createReviewerComment = (text, author) => ({
80
82
  *
81
83
  * The shared `ParaIdAllocatorExtension` mints RANDOM ids (correct for freshly
82
84
  * typed paragraphs in the live editor); this load-time pass is deterministic so
83
- * a paraId-less corpus document anchors reproducibly. The transaction is marked
84
- * ignore-tracked so the change baseline stays clean.
85
+ * a paraId-less corpus document anchors reproducibly.
86
+ *
87
+ * Rebuilding only the changed branches, like {@link ensureParaIdsInDoc}, keeps
88
+ * the pass linear in paragraph count. Seeding through a transaction instead
89
+ * costs one `setNodeMarkup` step per paragraph, and both halves of that are
90
+ * quadratic: every step rebuilds the containing fragment, and the plugin
91
+ * `appendTransaction` chain rescans the accumulated step maps. Because the pass
92
+ * runs before the state exists, no history, mapping, or change-tracking
93
+ * semantics depend on it.
85
94
  */
86
- const ensureDeterministicParaIdsInState = (state) => {
95
+ const ensureDeterministicParaIdsInDoc = (doc) => {
87
96
  const seen = /* @__PURE__ */ new Set();
88
- const updates = [];
89
97
  let ordinal = 0;
90
- state.doc.descendants((node, pos) => {
91
- if (node.type.name !== "paragraph") return;
92
- ordinal += 1;
93
- const existing = node.attrs["paraId"];
94
- if (typeof existing === "string" && existing.length > 0 && !seen.has(existing)) {
95
- seen.add(existing);
96
- return false;
97
- }
98
- let paraId = deterministicHexId(`${node.textContent}:${ordinal}`);
99
- for (let salt = 1; seen.has(paraId); salt++) paraId = deterministicHexId(`${node.textContent}:${ordinal}:${salt}`);
100
- seen.add(paraId);
101
- updates.push({
102
- pos,
103
- attrs: {
104
- ...node.attrs,
105
- paraId
98
+ const rewrite = (parent) => {
99
+ let changed = false;
100
+ const children = [];
101
+ parent.forEach((child) => {
102
+ let next = child;
103
+ if (child.type.name === "paragraph") {
104
+ ordinal += 1;
105
+ const existing = child.attrs["paraId"];
106
+ if (typeof existing === "string" && existing.length > 0 && !seen.has(existing)) seen.add(existing);
107
+ else {
108
+ let paraId = deterministicHexId(`${child.textContent}:${ordinal}`);
109
+ for (let salt = 1; seen.has(paraId); salt++) paraId = deterministicHexId(`${child.textContent}:${ordinal}:${salt}`);
110
+ seen.add(paraId);
111
+ next = child.type.create({
112
+ ...child.attrs,
113
+ paraId
114
+ }, child.content, child.marks);
115
+ }
116
+ } else if (child.childCount > 0) {
117
+ const content = rewrite(child);
118
+ if (content !== child.content) next = child.copy(content);
106
119
  }
120
+ if (next !== child) changed = true;
121
+ children.push(next);
107
122
  });
108
- return false;
109
- });
110
- if (updates.length === 0) return state;
111
- const tr = state.tr;
112
- for (const update of updates) tr.setNodeMarkup(update.pos, void 0, update.attrs);
113
- ignoreTrackedChanges(tr);
114
- tr.setMeta("addToHistory", false);
115
- return state.apply(tr);
123
+ return changed ? Fragment.fromArray(children) : parent.content;
124
+ };
125
+ const content = rewrite(doc);
126
+ return content === doc.content ? doc : doc.copy(content);
116
127
  };
117
128
  const FOLIO_REVIEWED_VIEWS = Object.freeze([
118
129
  "original",
@@ -225,9 +236,9 @@ var FolioDocxReviewer = class FolioDocxReviewer {
225
236
  password: options.password
226
237
  });
227
238
  const plugins = singletonManager.getPlugins();
228
- const state = ensureDeterministicParaIdsInState(EditorState.create({
239
+ const state = ensureBaseDirectionInState(EditorState.create({
229
240
  schema,
230
- doc: toProseDoc(baseDocument),
241
+ doc: ensureDeterministicParaIdsInDoc(toProseDoc(baseDocument)),
231
242
  plugins
232
243
  }));
233
244
  return new FolioDocxReviewer({
@@ -711,9 +722,10 @@ var FolioDocxReviewer = class FolioDocxReviewer {
711
722
  ...this.baseDocument.package.styles !== void 0 && { styles: this.baseDocument.package.styles },
712
723
  ...this.baseDocument.package.theme !== void 0 && { theme: this.baseDocument.package.theme }
713
724
  };
714
- const state = ensureDeterministicParaIdsInState(EditorState.create({
725
+ const storyDoc = story.type === "header" || story.type === "footer" ? headerFooterToProseDoc(source.content, conversionOptions) : footnoteToProseDoc(source.content, conversionOptions);
726
+ const state = ensureBaseDirectionInState(EditorState.create({
715
727
  schema,
716
- doc: story.type === "header" || story.type === "footer" ? headerFooterToProseDoc(source.content, conversionOptions) : footnoteToProseDoc(source.content, conversionOptions),
728
+ doc: ensureDeterministicParaIdsInDoc(storyDoc),
717
729
  plugins: singletonManager.getPlugins()
718
730
  }));
719
731
  this.secondaryStoryStates.set(key, {
@@ -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();
@@ -1,6 +1,41 @@
1
1
  //#region src/docx/server/extractDocxText.d.ts
2
2
  /** Document part containing an extracted paragraph. */
3
3
  type DocxParagraphSource = "header" | "body" | "footer";
4
+ /**
5
+ * Role of an emitted markdown table row.
6
+ *
7
+ * - `cells` — a `w:tr` rendered as a pipe row, including the first row when the
8
+ * table declares it as its header.
9
+ * - `syntheticHeader` — the empty header row emitted for a table that declares
10
+ * no header row; GFM has no headerless table.
11
+ * - `delimiter` — the `| --- |` line GFM requires under the header.
12
+ */
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
+ };
27
+ /** Table membership of a paragraph whose `text` is a markdown table row. */
28
+ type DocxTableRowPosition = {
29
+ /** 0-based index of the source `w:tbl`, in extraction order across all parts. */
30
+ table: number;
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";
38
+ };
4
39
  /** Paragraph text and lightweight formatting metadata from a DOCX archive. */
5
40
  type ExtractedDocxParagraph = {
6
41
  index: number;
@@ -10,6 +45,13 @@ type ExtractedDocxParagraph = {
10
45
  bold?: boolean;
11
46
  fontSize?: number;
12
47
  alignment?: "left" | "center" | "right" | "both";
48
+ /**
49
+ * Present only when `text` is a markdown table row rendered from a `w:tbl`,
50
+ * absent for ordinary prose paragraphs. Consumers that join `text` across
51
+ * paragraphs need no change; consumers that want to regroup a table's rows,
52
+ * or drop the rows GFM forced into existence, can key off this.
53
+ */
54
+ tableRow?: DocxTableRowPosition;
13
55
  };
14
56
  /** Accepted-revision paragraph text extracted in deterministic part order. */
15
57
  type ExtractedDocxText = {
@@ -20,4 +62,4 @@ type ExtractedDocxText = {
20
62
  /** Extract paragraph text and formatting metadata from a DOCX archive. */
21
63
  declare const extractDocxText: (bytes: ArrayBuffer | Uint8Array) => Promise<ExtractedDocxText>;
22
64
  //#endregion
23
- export { DocxParagraphSource, ExtractedDocxParagraph, ExtractedDocxText, extractDocxText };
65
+ export { DocxParagraphSource, DocxTableRowKind, DocxTableRowPosition, ExtractedDocxParagraph, ExtractedDocxTableCell, ExtractedDocxTableCellParagraph, ExtractedDocxText, extractDocxText };
@@ -1,13 +1,21 @@
1
+ import { escapeTableCell } from "../../markdown/escape.js";
1
2
  import { RELATIONSHIP_TYPES, parseRelationships } from "../relsParser.js";
2
- 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";
3
4
  import { loadDocxArchive } from "./boundedArchive.js";
4
5
  //#region src/docx/server/extractDocxText.ts
5
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"]);
6
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);
7
15
  const collectText = (element) => {
8
16
  let text = "";
9
17
  const walk = (node) => {
10
- const localName = getLocalName(node.name ?? "");
18
+ const localName = wordElementName(node);
11
19
  if (localName === "t") {
12
20
  text += getTextContent(node);
13
21
  return;
@@ -26,29 +34,45 @@ const collectText = (element) => {
26
34
  walk(element);
27
35
  return text;
28
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
+ };
29
51
  const readParagraphProperties = (paragraph) => {
30
- const properties = findChild(paragraph, "w", "pPr");
52
+ const properties = findWordChild(paragraph, "pPr");
31
53
  if (!properties) return {};
32
54
  const result = {};
33
- const styleValue = getAttributeAnyPrefix(findChild(properties, "w", "pStyle"), "val");
55
+ const style = findWordChild(properties, "pStyle");
56
+ const styleValue = getWordAttribute(style, "val");
34
57
  if (styleValue !== null) result.style = styleValue;
35
- const alignment = getAttributeAnyPrefix(findChild(properties, "w", "jc"), "val");
58
+ const justification = findWordChild(properties, "jc");
59
+ const alignment = getWordAttribute(justification, "val");
36
60
  if (alignment === "left" || alignment === "center" || alignment === "right" || alignment === "both") result.alignment = alignment;
37
61
  return result;
38
62
  };
39
63
  const readRunMetrics = (paragraph) => {
40
64
  const metrics = [];
41
65
  for (const run of childElements(paragraph)) {
42
- if (getLocalName(run.name ?? "") !== "r") continue;
43
- const properties = findChild(run, "w", "rPr");
44
- const boldProperty = findChild(properties, "w", "b");
45
- 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");
46
70
  const bold = boldProperty !== null && boldValue !== "0" && boldValue !== "false";
47
- const sizeValue = getAttributeAnyPrefix(findChild(properties, "w", "sz"), "val");
71
+ const sizeProperty = findWordChild(properties, "sz");
72
+ const sizeValue = getWordAttribute(sizeProperty, "val");
48
73
  const parsedSize = sizeValue === null ? NaN : Number.parseInt(sizeValue, 10);
49
74
  const fontSize = Number.isFinite(parsedSize) && parsedSize > 0 ? parsedSize : void 0;
50
- let chars = 0;
51
- for (const textNode of findAllDeep(run, "w", "t")) chars += getTextContent(textNode).length;
75
+ const chars = countAcceptedTextChars(run);
52
76
  if (chars === 0) continue;
53
77
  const entry = {
54
78
  bold,
@@ -59,37 +83,269 @@ const readRunMetrics = (paragraph) => {
59
83
  }
60
84
  return metrics;
61
85
  };
62
- const extractContainer = ({ container, source, startIndex }) => {
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
+ };
99
+ const TABLE_DELIMITER_CELL = "---";
100
+ /** GFM cannot nest tables; an inner table joins its cells inside the outer cell. */
101
+ const NESTED_TABLE_CELL_SEPARATOR = " / ";
102
+ /** Word supports 63 table columns; cap well above that so a hostile `w:gridSpan` cannot balloon a row. */
103
+ const MAX_TABLE_COLUMNS = 256;
104
+ /** Bound the mutual recursion between a cell and the tables nested inside it. */
105
+ const MAX_NESTED_TABLE_DEPTH = 8;
106
+ /**
107
+ * Collect a table's `w:tr`, or a row's `w:tc`, seeing through the wrappers Word
108
+ * puts around them (`w:sdt` / `w:sdtContent` content controls, `w:customXml`).
109
+ * The walk stops at `w:tbl` and `w:p` so a nested table's rows and cells never
110
+ * leak into the grid of the table that contains them.
111
+ */
112
+ const collectTableParts = (parent, localName) => {
113
+ const parts = [];
114
+ const walk = (node) => {
115
+ for (const child of childElements(node)) {
116
+ const childName = wordElementName(child);
117
+ if (childName === localName) {
118
+ parts.push(child);
119
+ continue;
120
+ }
121
+ if (childName === "tbl" || childName === "p") continue;
122
+ walk(child);
123
+ }
124
+ };
125
+ walk(parent);
126
+ return parts;
127
+ };
128
+ /**
129
+ * Raw (unescaped) text of one cell: its paragraphs in order, one per line.
130
+ * Blank paragraphs are dropped so a cell padded with empty paragraphs does not
131
+ * render as a run of `<br>`. A nested table contributes one line per inner row.
132
+ */
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) => {
155
+ const lines = [];
156
+ const walk = (node) => {
157
+ for (const child of childElements(node)) {
158
+ const childName = wordElementName(child);
159
+ if (childName === "p") {
160
+ const text = collectText(child);
161
+ if (text.length > 0) lines.push(text);
162
+ continue;
163
+ }
164
+ if (childName === "tbl") {
165
+ if (depth < MAX_NESTED_TABLE_DEPTH) for (const line of flattenNestedTable(child, depth + 1)) lines.push(line);
166
+ continue;
167
+ }
168
+ walk(child);
169
+ }
170
+ };
171
+ walk(cell);
172
+ return lines;
173
+ };
174
+ const flattenNestedTable = (table, depth) => {
175
+ const lines = [];
176
+ for (const row of collectTableParts(table, "tr")) {
177
+ const cells = collectTableParts(row, "tc").map((cell) => readCellRenderedLines(cell, depth).join("\n"));
178
+ if (cells.some((text) => text.length > 0)) lines.push(cells.join(NESTED_TABLE_CELL_SEPARATOR));
179
+ }
180
+ return lines;
181
+ };
182
+ const emptyTableCell = () => ({
183
+ text: "",
184
+ paragraphs: [],
185
+ gridSpan: 1
186
+ });
187
+ const readTableCell = (cell, depth) => {
188
+ const properties = findWordChild(cell, "tcPr");
189
+ const gridSpanValue = getWordAttribute(findWordChild(properties, "gridSpan"), "val");
190
+ const parsedGridSpan = gridSpanValue === null ? 1 : Number.parseInt(gridSpanValue, 10);
191
+ const gridSpan = Number.isFinite(parsedGridSpan) && parsedGridSpan > 1 ? parsedGridSpan : 1;
192
+ const vMerge = findWordChild(properties, "vMerge");
193
+ if (vMerge !== null && getWordAttribute(vMerge, "val") !== "restart") return {
194
+ text: "",
195
+ paragraphs: [],
196
+ gridSpan
197
+ };
198
+ const paragraphs = readCellSourceParagraphs(cell, depth);
199
+ return {
200
+ text: readCellRenderedLines(cell, depth).join("\n"),
201
+ paragraphs,
202
+ gridSpan
203
+ };
204
+ };
205
+ /**
206
+ * Does the row declare itself a header? `w:tblHeader` is the only OOXML signal
207
+ * that says so: it marks the row Word repeats at the top of each page. The
208
+ * neighbouring `w:tblLook/@w:firstRow` is conditional *formatting* that Word
209
+ * writes on essentially every table (its default `w:val="04A0"`), so keying off
210
+ * it would promote the first data row of almost every document.
211
+ *
212
+ * Without the flag the table is headerless and GFM gets a synthetic empty header
213
+ * row: a table's first row is data until the document says otherwise, and column
214
+ * names invented here would be read back as facts about the document.
215
+ */
216
+ const declaresHeaderRow = (row) => {
217
+ const header = findWordChild(findWordChild(row, "trPr"), "tblHeader");
218
+ if (header === null) return false;
219
+ const value = getWordAttribute(header, "val");
220
+ return value !== "0" && value !== "false";
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
+ };
228
+ const readTableGrid = (table) => {
229
+ const rows = [];
230
+ let columnCount = 0;
231
+ let firstRowIsHeader = false;
232
+ for (const [rowIndex, row] of collectTableParts(table, "tr").entries()) {
233
+ if (rowIndex === 0) firstRowIsHeader = declaresHeaderRow(row);
234
+ const columns = [];
235
+ const gridBefore = readRowGridOffset(row, "gridBefore");
236
+ for (let index = 0; index < gridBefore; index += 1) columns.push(emptyTableCell());
237
+ for (const cell of collectTableParts(row, "tc")) {
238
+ if (columns.length >= MAX_TABLE_COLUMNS) break;
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());
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());
246
+ if (columns.length > columnCount) columnCount = columns.length;
247
+ rows.push(columns);
248
+ }
249
+ return {
250
+ rows,
251
+ columnCount,
252
+ firstRowIsHeader
253
+ };
254
+ };
255
+ /** Pad a row to the table's column count and escape each cell into a pipe row. */
256
+ const toRowLine = (columns, columnCount) => {
257
+ const cells = [];
258
+ for (let column = 0; column < columnCount; column++) cells.push(escapeTableCell(columns[column]?.text ?? ""));
259
+ return `| ${cells.join(" | ")} |`;
260
+ };
261
+ /** Render a `w:tbl` as GFM rows. A table with no cell at all renders nothing. */
262
+ const renderTableRows = (table, tableIndex) => {
263
+ const { rows, columnCount, firstRowIsHeader } = readTableGrid(table);
264
+ const [firstRow, ...remainingRows] = rows;
265
+ if (columnCount === 0 || firstRow === void 0) return [];
266
+ const rendered = [];
267
+ const pushScaffolding = (text, kind) => {
268
+ rendered.push({
269
+ text,
270
+ position: {
271
+ table: tableIndex,
272
+ kind
273
+ }
274
+ });
275
+ };
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);
294
+ return rendered;
295
+ };
296
+ const extractContainer = ({ container, source, startIndex, startTableIndex }) => {
63
297
  const paragraphs = [];
64
298
  let charCount = 0;
65
- for (const [offset, paragraph] of findAllDeep(container, "w", "p").entries()) {
66
- const text = collectText(paragraph);
299
+ let tableCount = 0;
300
+ const pushProse = (paragraph) => {
301
+ const extracted = readParagraph(paragraph);
302
+ const { text } = extracted;
67
303
  const entry = {
68
- index: startIndex + offset,
69
- text,
70
- source
304
+ index: startIndex + paragraphs.length,
305
+ source,
306
+ ...extracted
71
307
  };
72
- const { style, alignment } = readParagraphProperties(paragraph);
73
- if (style !== void 0) entry.style = style;
74
- if (alignment !== void 0) entry.alignment = alignment;
75
- const runs = readRunMetrics(paragraph);
76
- if (runs.length > 0) {
77
- const totalChars = runs.reduce((sum, run) => sum + run.chars, 0);
78
- if (runs.reduce((sum, run) => sum + (run.bold ? run.chars : 0), 0) > totalChars / 2) entry.bold = true;
79
- const firstFontSize = runs.find((run) => run.fontSize !== void 0)?.fontSize;
80
- if (firstFontSize !== void 0) entry.fontSize = firstFontSize;
81
- }
82
308
  paragraphs.push(entry);
83
309
  charCount += text.length;
84
- }
310
+ };
311
+ const pushTableRow = ({ text, position }) => {
312
+ paragraphs.push({
313
+ index: startIndex + paragraphs.length,
314
+ text,
315
+ source,
316
+ tableRow: position
317
+ });
318
+ charCount += text.length;
319
+ };
320
+ /**
321
+ * Walk block content in document order. Descent mirrors the previous
322
+ * `findAllDeep(container, "w", "p")` — every wrapper (`w:sdt`, textboxes) is
323
+ * still entered — except that a `w:tbl` is consumed as a table instead of
324
+ * having its cell paragraphs emitted individually.
325
+ */
326
+ const walkBlocks = (node) => {
327
+ for (const child of childElements(node)) {
328
+ const childName = wordElementName(child);
329
+ if (childName === "tbl") {
330
+ for (const row of renderTableRows(child, startTableIndex + tableCount)) pushTableRow(row);
331
+ tableCount += 1;
332
+ continue;
333
+ }
334
+ if (childName === "p") pushProse(child);
335
+ walkBlocks(child);
336
+ }
337
+ };
338
+ walkBlocks(container);
85
339
  return {
86
340
  paragraphs,
87
- charCount
341
+ charCount,
342
+ tableCount
88
343
  };
89
344
  };
90
- const extractParts = async ({ archive, source, rootName, startIndex, paths }) => {
345
+ const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths }) => {
91
346
  const paragraphs = [];
92
347
  let charCount = 0;
348
+ let tableCount = 0;
93
349
  let nextIndex = startIndex;
94
350
  for (const path of paths) {
95
351
  const xml = await archive.readEntryString(path);
@@ -99,15 +355,18 @@ const extractParts = async ({ archive, source, rootName, startIndex, paths }) =>
99
355
  const result = extractContainer({
100
356
  container,
101
357
  source,
102
- startIndex: nextIndex
358
+ startIndex: nextIndex,
359
+ startTableIndex: startTableIndex + tableCount
103
360
  });
104
- paragraphs.push(...result.paragraphs);
361
+ for (const paragraph of result.paragraphs) paragraphs.push(paragraph);
105
362
  charCount += result.charCount;
363
+ tableCount += result.tableCount;
106
364
  nextIndex += result.paragraphs.length;
107
365
  }
108
366
  return {
109
367
  paragraphs,
110
- charCount
368
+ charCount,
369
+ tableCount
111
370
  };
112
371
  };
113
372
  /** A `word/_rels/document.xml.rels` `Target` is relative to `word/`; resolve it to a full archive-entry path. */
@@ -173,18 +432,21 @@ const extractDocxText = async (bytes) => {
173
432
  source: "header",
174
433
  rootName: "hdr",
175
434
  startIndex: 0,
435
+ startTableIndex: 0,
176
436
  paths: referencedParts.headers
177
437
  });
178
438
  const bodyResult = extractContainer({
179
439
  container: body,
180
440
  source: "body",
181
- startIndex: headers.paragraphs.length
441
+ startIndex: headers.paragraphs.length,
442
+ startTableIndex: headers.tableCount
182
443
  });
183
444
  const footers = await extractParts({
184
445
  archive,
185
446
  source: "footer",
186
447
  rootName: "ftr",
187
448
  startIndex: headers.paragraphs.length + bodyResult.paragraphs.length,
449
+ startTableIndex: headers.tableCount + bodyResult.tableCount,
188
450
  paths: referencedParts.footers
189
451
  });
190
452
  return {
@@ -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 };