@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.
- package/dist/ai-edits/headless.js +45 -33
- package/dist/controller/fontReadiness.js +2 -0
- package/dist/docx/server/extractDocxText.d.ts +43 -1
- package/dist/docx/server/extractDocxText.js +297 -35
- 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 +52 -10
- 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 +2 -2
|
@@ -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 {
|
|
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.
|
|
84
|
-
*
|
|
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
|
|
95
|
+
const ensureDeterministicParaIdsInDoc = (doc) => {
|
|
87
96
|
const seen = /* @__PURE__ */ new Set();
|
|
88
|
-
const updates = [];
|
|
89
97
|
let ordinal = 0;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
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,
|
|
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 =
|
|
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 =
|
|
52
|
+
const properties = findWordChild(paragraph, "pPr");
|
|
31
53
|
if (!properties) return {};
|
|
32
54
|
const result = {};
|
|
33
|
-
const
|
|
55
|
+
const style = findWordChild(properties, "pStyle");
|
|
56
|
+
const styleValue = getWordAttribute(style, "val");
|
|
34
57
|
if (styleValue !== null) result.style = styleValue;
|
|
35
|
-
const
|
|
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 (
|
|
43
|
-
const properties =
|
|
44
|
-
const boldProperty =
|
|
45
|
-
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");
|
|
46
70
|
const bold = boldProperty !== null && boldValue !== "0" && boldValue !== "false";
|
|
47
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
299
|
+
let tableCount = 0;
|
|
300
|
+
const pushProse = (paragraph) => {
|
|
301
|
+
const extracted = readParagraph(paragraph);
|
|
302
|
+
const { text } = extracted;
|
|
67
303
|
const entry = {
|
|
68
|
-
index: startIndex +
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 {
|
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 };
|