@stll/folio-core 0.20.0 → 0.22.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/docx/paragraphParser.js +6 -4
- package/dist/docx/parser.js +6 -2
- package/dist/docx/rezip.d.ts +9 -1
- package/dist/docx/rezip.js +108 -41
- package/dist/docx/selectiveSave.js +6 -0
- package/dist/docx/selectiveXmlPatch.d.ts +19 -1
- package/dist/docx/selectiveXmlPatch.js +72 -1
- package/dist/docx/serializer/paragraphSerializer.js +1 -0
- package/dist/docx/serializer/settingsSerializer.js +1 -0
- package/dist/docx/serializer/stylesSerializer.d.ts +2 -1
- package/dist/docx/serializer/stylesSerializer.js +1 -1
- package/dist/docx/server/build.d.ts +103 -0
- package/dist/docx/server/build.js +265 -0
- package/dist/docx/server/createBilingualDocument.d.ts +62 -0
- package/dist/docx/server/createBilingualDocument.js +511 -0
- package/dist/docx/server/createBilingualDocx.d.ts +19 -0
- package/dist/docx/server/createBilingualDocx.js +26 -0
- package/dist/docx/settingsParser.js +8 -5
- package/dist/docx/xmlParser.d.ts +9 -1
- package/dist/docx/xmlParser.js +13 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.js +5 -1
- package/dist/style-sets/stellaStyle.js +146 -0
- package/dist/utils/createDocument.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { deterministicHexId } from "../../utils/hexId.js";
|
|
2
|
+
import { getParagraphText } from "../paragraphParser.js";
|
|
3
|
+
import { TaggedError } from "better-result";
|
|
4
|
+
//#region src/docx/server/createBilingualDocument.ts
|
|
5
|
+
/**
|
|
6
|
+
* Bilingual document transform: body -> two-column table, one row per block.
|
|
7
|
+
*
|
|
8
|
+
* The left column keeps every source block untouched; the right column holds a
|
|
9
|
+
* copy of the same block whose numbering and numbered paragraph styles are
|
|
10
|
+
* cloned per language, so both columns count independently (1. / 1. instead of
|
|
11
|
+
* 1. / 2.) and stay live in Word. Right-column paragraphs receive fresh
|
|
12
|
+
* `paraId`s so callers can address each row later (for example to replace the
|
|
13
|
+
* placeholder copy with a translation by block id).
|
|
14
|
+
*
|
|
15
|
+
* Section breaks cannot live inside a table cell, so the body is split at
|
|
16
|
+
* paragraphs carrying `sectionProperties`: each section becomes its own table
|
|
17
|
+
* and the break paragraph stays between the tables. A source table (parties,
|
|
18
|
+
* signature block) is kept once, in a row spanning both columns: it is signed
|
|
19
|
+
* and read once, and its labels are translated inline rather than duplicated.
|
|
20
|
+
*/
|
|
21
|
+
const STYLE_SUFFIX_PATTERN = /^[A-Za-z0-9-]+$/u;
|
|
22
|
+
var InvalidBilingualDocumentOptionsError = class extends TaggedError("InvalidBilingualDocumentOptionsError")() {};
|
|
23
|
+
const FULL_WIDTH_PCT = 5e3;
|
|
24
|
+
const HALF_WIDTH_PCT = 2500;
|
|
25
|
+
const A4_TEXT_WIDTH_TWIPS = 9072;
|
|
26
|
+
const ROW_ID_NAMESPACE = "folio-bilingual";
|
|
27
|
+
const GRID_BORDER = {
|
|
28
|
+
style: "single",
|
|
29
|
+
size: 4,
|
|
30
|
+
space: 0
|
|
31
|
+
};
|
|
32
|
+
const TABLE_BORDERS = {
|
|
33
|
+
none: {
|
|
34
|
+
top: { style: "nil" },
|
|
35
|
+
bottom: { style: "nil" },
|
|
36
|
+
left: { style: "nil" },
|
|
37
|
+
right: { style: "nil" },
|
|
38
|
+
insideH: { style: "nil" },
|
|
39
|
+
insideV: { style: "nil" }
|
|
40
|
+
},
|
|
41
|
+
grid: {
|
|
42
|
+
top: GRID_BORDER,
|
|
43
|
+
bottom: GRID_BORDER,
|
|
44
|
+
left: GRID_BORDER,
|
|
45
|
+
right: GRID_BORDER,
|
|
46
|
+
insideH: GRID_BORDER,
|
|
47
|
+
insideV: GRID_BORDER
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
function createBilingualDocument(source, options) {
|
|
51
|
+
if (!STYLE_SUFFIX_PATTERN.test(options.targetStyleSuffix)) throw new InvalidBilingualDocumentOptionsError({
|
|
52
|
+
message: `targetStyleSuffix must match ${STYLE_SUFFIX_PATTERN}; received ${JSON.stringify(options.targetStyleSuffix)}`,
|
|
53
|
+
option: "targetStyleSuffix"
|
|
54
|
+
});
|
|
55
|
+
const borders = options.borders ?? "none";
|
|
56
|
+
const warnings = [];
|
|
57
|
+
const styles = source.package.styles;
|
|
58
|
+
const numbering = source.package.numbering;
|
|
59
|
+
const styleById = new Map((styles?.styles ?? []).map((style) => [style.styleId, style]));
|
|
60
|
+
const blocks = flattenBlocks(source.package.document.content);
|
|
61
|
+
const cloner = createNumberingCloner({
|
|
62
|
+
numbering,
|
|
63
|
+
styleById,
|
|
64
|
+
warnings
|
|
65
|
+
});
|
|
66
|
+
const styleCloner = createStyleCloner({
|
|
67
|
+
styleById,
|
|
68
|
+
suffix: options.targetStyleSuffix,
|
|
69
|
+
cloner
|
|
70
|
+
});
|
|
71
|
+
const paraIds = createParaIdMinter(collectPackageParaIds(source.package));
|
|
72
|
+
const rows = [];
|
|
73
|
+
const content = [];
|
|
74
|
+
let sectionRows = [];
|
|
75
|
+
const textWidth = resolveTextWidthTwips(source);
|
|
76
|
+
const flushSection = () => {
|
|
77
|
+
if (sectionRows.length > 0) content.push(buildTable(sectionRows, borders, textWidth));
|
|
78
|
+
sectionRows = [];
|
|
79
|
+
};
|
|
80
|
+
const copyParagraph = (paragraph) => {
|
|
81
|
+
const targetParaId = paraIds.mint(paragraph.paraId);
|
|
82
|
+
return {
|
|
83
|
+
copy: cloneParagraphForTarget(paragraph, targetParaId, styleCloner, cloner),
|
|
84
|
+
ref: {
|
|
85
|
+
sourceParaId: paragraph.paraId,
|
|
86
|
+
targetParaId,
|
|
87
|
+
sourceText: getParagraphText(paragraph)
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
for (const block of blocks) {
|
|
92
|
+
if (block.type === "paragraph" && block.sectionProperties) {
|
|
93
|
+
flushSection();
|
|
94
|
+
content.push(block);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (block.type === "paragraph") {
|
|
98
|
+
if (isEmptyParagraph(block)) continue;
|
|
99
|
+
const { copy, ref } = copyParagraph(block);
|
|
100
|
+
rows.push({
|
|
101
|
+
kind: classifyParagraph(block, styleById),
|
|
102
|
+
rowId: ref.targetParaId,
|
|
103
|
+
...ref
|
|
104
|
+
});
|
|
105
|
+
sectionRows.push(buildRow(block, copy));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const paragraphs = collectTableParagraphs(block).map((paragraph) => ({
|
|
109
|
+
paraId: paragraph.paraId,
|
|
110
|
+
sourceText: getParagraphText(paragraph)
|
|
111
|
+
}));
|
|
112
|
+
rows.push({
|
|
113
|
+
kind: "table",
|
|
114
|
+
rowId: paragraphs.at(0)?.paraId ?? tableRowHandle(rows.length),
|
|
115
|
+
paragraphs
|
|
116
|
+
});
|
|
117
|
+
sectionRows.push(buildSpanningRow(block));
|
|
118
|
+
}
|
|
119
|
+
flushSection();
|
|
120
|
+
return {
|
|
121
|
+
document: {
|
|
122
|
+
...source,
|
|
123
|
+
package: {
|
|
124
|
+
...source.package,
|
|
125
|
+
document: {
|
|
126
|
+
...source.package.document,
|
|
127
|
+
content
|
|
128
|
+
},
|
|
129
|
+
...cloner.hasClones() && { numbering: cloner.toDefinitions() },
|
|
130
|
+
...styleCloner.hasClones() && styles && { styles: styleCloner.toDefinitions(styles) }
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
rows,
|
|
134
|
+
warnings
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Handle for a table row whose paragraphs carry no `paraId`: its position in
|
|
138
|
+
* the manifest, which creation and reading derive identically. */
|
|
139
|
+
const tableRowHandle = (index) => `table-${index}`;
|
|
140
|
+
/** Top-level body blocks with content controls flattened to their children. */
|
|
141
|
+
const flattenBlocks = (content) => {
|
|
142
|
+
const out = [];
|
|
143
|
+
const visit = (block) => {
|
|
144
|
+
if (block.type === "paragraph" || block.type === "table") {
|
|
145
|
+
out.push(block);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const child of block.content) visit(child);
|
|
149
|
+
};
|
|
150
|
+
for (const block of content) visit(block);
|
|
151
|
+
return out;
|
|
152
|
+
};
|
|
153
|
+
const isEmptyParagraph = (paragraph) => {
|
|
154
|
+
if (getParagraphText(paragraph).trim().length > 0) return false;
|
|
155
|
+
return paragraph.content.every((item) => item.type === "run" && item.content.every((part) => part.type === "text"));
|
|
156
|
+
};
|
|
157
|
+
/** Heading style families across Word UI languages (en, cs/sk, de, fr, pl). */
|
|
158
|
+
const HEADING_STYLE_PATTERN = /heading|nadpis|berschrift|titre|nag[łl]/iu;
|
|
159
|
+
const classifyParagraph = (paragraph, styleById) => {
|
|
160
|
+
const formatting = paragraph.formatting;
|
|
161
|
+
const style = formatting?.styleId ? styleById.get(formatting.styleId) : void 0;
|
|
162
|
+
const outlineLevel = formatting?.outlineLevel ?? resolveInheritedOutlineLevel(style, styleById);
|
|
163
|
+
if (outlineLevel !== void 0 && outlineLevel < 9) return "heading";
|
|
164
|
+
if (style && (HEADING_STYLE_PATTERN.test(style.styleId) || HEADING_STYLE_PATTERN.test(style.name ?? ""))) return "heading";
|
|
165
|
+
if (effectiveNumPr(paragraph, styleById) !== void 0) return "listItem";
|
|
166
|
+
return "paragraph";
|
|
167
|
+
};
|
|
168
|
+
const resolveInheritedOutlineLevel = (style, styleById) => {
|
|
169
|
+
const seen = /* @__PURE__ */ new Set();
|
|
170
|
+
let current = style;
|
|
171
|
+
while (current && !seen.has(current.styleId)) {
|
|
172
|
+
seen.add(current.styleId);
|
|
173
|
+
if (current.pPr?.outlineLevel !== void 0) return current.pPr.outlineLevel;
|
|
174
|
+
current = current.basedOn ? styleById.get(current.basedOn) : void 0;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/** The numbering a paragraph renders with: direct `numPr`, else the style chain's. */
|
|
178
|
+
const effectiveNumPr = (paragraph, styleById) => {
|
|
179
|
+
const direct = paragraph.formatting?.numPr;
|
|
180
|
+
if (direct?.numId !== void 0) return direct.numId === 0 ? void 0 : direct;
|
|
181
|
+
const styleId = paragraph.formatting?.styleId;
|
|
182
|
+
return styleId ? styleNumPr(styleById.get(styleId), styleById) : void 0;
|
|
183
|
+
};
|
|
184
|
+
const styleNumPr = (style, styleById) => {
|
|
185
|
+
const seen = /* @__PURE__ */ new Set();
|
|
186
|
+
let current = style;
|
|
187
|
+
while (current && !seen.has(current.styleId)) {
|
|
188
|
+
seen.add(current.styleId);
|
|
189
|
+
const numPr = current.pPr?.numPr;
|
|
190
|
+
if (numPr?.numId !== void 0) return numPr.numId === 0 ? void 0 : numPr;
|
|
191
|
+
current = current.basedOn ? styleById.get(current.basedOn) : void 0;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const createNumberingCloner = ({ numbering, styleById, warnings }) => {
|
|
195
|
+
const abstractNums = numbering?.abstractNums ?? [];
|
|
196
|
+
const nums = numbering?.nums ?? [];
|
|
197
|
+
const abstractById = new Map(abstractNums.map((item) => [item.abstractNumId, item]));
|
|
198
|
+
const numById = new Map(nums.map((item) => [item.numId, item]));
|
|
199
|
+
let nextAbstractNumId = Math.max(0, ...abstractNums.map((item) => item.abstractNumId)) + 1;
|
|
200
|
+
let nextNumId = Math.max(0, ...nums.map((item) => item.numId)) + 1;
|
|
201
|
+
const clonedAbstract = /* @__PURE__ */ new Map();
|
|
202
|
+
const clonedNum = /* @__PURE__ */ new Map();
|
|
203
|
+
/**
|
|
204
|
+
* Word keys list counters by the abstract definition a `w:num` points at;
|
|
205
|
+
* two instances sharing one abstract continue the same sequence. A clone
|
|
206
|
+
* therefore needs its own abstract, and an abstract that only links to a
|
|
207
|
+
* numbering style must be materialized from that style's levels, otherwise
|
|
208
|
+
* both clones resolve to the same linked definition and share counters.
|
|
209
|
+
*/
|
|
210
|
+
const cloneAbstract = (sourceId) => {
|
|
211
|
+
const existing = clonedAbstract.get(sourceId);
|
|
212
|
+
if (existing) return existing;
|
|
213
|
+
const source = abstractById.get(sourceId);
|
|
214
|
+
if (!source) return;
|
|
215
|
+
const resolved = resolveLinkedAbstract(source);
|
|
216
|
+
const { numStyleLink: _numStyleLink, styleLink: _styleLink, ...rest } = resolved;
|
|
217
|
+
const clone = {
|
|
218
|
+
...rest,
|
|
219
|
+
abstractNumId: nextAbstractNumId,
|
|
220
|
+
levels: structuredClone(resolved.levels)
|
|
221
|
+
};
|
|
222
|
+
nextAbstractNumId += 1;
|
|
223
|
+
clonedAbstract.set(sourceId, clone);
|
|
224
|
+
return clone;
|
|
225
|
+
};
|
|
226
|
+
const resolveLinkedAbstract = (abstract) => {
|
|
227
|
+
const seen = /* @__PURE__ */ new Set();
|
|
228
|
+
let current = abstract;
|
|
229
|
+
while (current.numStyleLink && !seen.has(current.abstractNumId)) {
|
|
230
|
+
seen.add(current.abstractNumId);
|
|
231
|
+
const linkedNumId = styleById.get(current.numStyleLink)?.pPr?.numPr?.numId;
|
|
232
|
+
const linkedNum = linkedNumId === void 0 ? void 0 : numById.get(linkedNumId);
|
|
233
|
+
const linkedAbstract = linkedNum ? abstractById.get(linkedNum.abstractNumId) : void 0;
|
|
234
|
+
if (!linkedAbstract) {
|
|
235
|
+
warnings.push(`Numbering style link "${current.numStyleLink}" on abstractNum ${current.abstractNumId} could not be resolved; the clone keeps the link.`);
|
|
236
|
+
return current;
|
|
237
|
+
}
|
|
238
|
+
current = linkedAbstract;
|
|
239
|
+
}
|
|
240
|
+
return current;
|
|
241
|
+
};
|
|
242
|
+
const cloneNumId = (numId) => {
|
|
243
|
+
const existing = clonedNum.get(numId);
|
|
244
|
+
if (existing) return existing.numId;
|
|
245
|
+
const source = numById.get(numId);
|
|
246
|
+
if (!source) {
|
|
247
|
+
warnings.push(`Numbering instance ${numId} is not defined; paragraphs using it keep the source instance.`);
|
|
248
|
+
return numId;
|
|
249
|
+
}
|
|
250
|
+
const abstract = cloneAbstract(source.abstractNumId);
|
|
251
|
+
if (!abstract) warnings.push(`Numbering instance ${numId} references abstractNum ${source.abstractNumId}, which is not defined; its copy shares the source counters.`);
|
|
252
|
+
const clone = {
|
|
253
|
+
...source,
|
|
254
|
+
numId: nextNumId,
|
|
255
|
+
abstractNumId: abstract ? abstract.abstractNumId : source.abstractNumId
|
|
256
|
+
};
|
|
257
|
+
nextNumId += 1;
|
|
258
|
+
clonedNum.set(numId, clone);
|
|
259
|
+
return clone.numId;
|
|
260
|
+
};
|
|
261
|
+
return {
|
|
262
|
+
cloneNumId,
|
|
263
|
+
clonedAbstractNumId: (abstractNumId) => clonedAbstract.get(abstractNumId)?.abstractNumId,
|
|
264
|
+
hasClones: () => clonedNum.size > 0,
|
|
265
|
+
toDefinitions: () => ({
|
|
266
|
+
abstractNums: [...abstractNums, ...clonedAbstract.values()],
|
|
267
|
+
nums: [...nums, ...clonedNum.values()]
|
|
268
|
+
})
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
/**
|
|
272
|
+
* A paragraph style is cloned only when its chain carries numbering. The clone
|
|
273
|
+
* keeps `basedOn` and every other property; only `pPr.numPr` is rewritten to
|
|
274
|
+
* the cloned instance, so indent precedence stays "style-sourced" exactly as
|
|
275
|
+
* in the source (see `ParagraphFormatting.numPrFromStyle`).
|
|
276
|
+
*/
|
|
277
|
+
const createStyleCloner = ({ styleById, suffix, cloner }) => {
|
|
278
|
+
const clones = /* @__PURE__ */ new Map();
|
|
279
|
+
const styleIdFor = (styleId) => {
|
|
280
|
+
const existing = clones.get(styleId);
|
|
281
|
+
if (existing) return existing.styleId;
|
|
282
|
+
const style = styleById.get(styleId);
|
|
283
|
+
if (!style || style.type !== "paragraph") return styleId;
|
|
284
|
+
const numPr = styleNumPr(style, styleById);
|
|
285
|
+
if (numPr?.numId === void 0) return styleId;
|
|
286
|
+
const cloneId = `${styleId}-${suffix}`;
|
|
287
|
+
if (styleById.has(cloneId)) return cloneId;
|
|
288
|
+
const clone = {
|
|
289
|
+
...style,
|
|
290
|
+
styleId: cloneId,
|
|
291
|
+
name: `${style.name ?? style.styleId} (${suffix})`,
|
|
292
|
+
...style.next !== void 0 && { next: style.next === styleId ? cloneId : style.next },
|
|
293
|
+
default: false,
|
|
294
|
+
pPr: {
|
|
295
|
+
...style.pPr,
|
|
296
|
+
numPr: {
|
|
297
|
+
...numPr,
|
|
298
|
+
numId: cloner.cloneNumId(numPr.numId)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
clones.set(styleId, clone);
|
|
303
|
+
return cloneId;
|
|
304
|
+
};
|
|
305
|
+
return {
|
|
306
|
+
styleIdFor,
|
|
307
|
+
hasClones: () => clones.size > 0,
|
|
308
|
+
toDefinitions: (styles) => ({
|
|
309
|
+
...styles,
|
|
310
|
+
styles: [...styles.styles, ...clones.values()]
|
|
311
|
+
})
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
const cloneParagraphForTarget = (paragraph, targetParaId, styleCloner, cloner) => {
|
|
315
|
+
const { textId: _textId, sectionProperties: _sectionProperties, ...rest } = paragraph;
|
|
316
|
+
const formatting = paragraph.formatting;
|
|
317
|
+
const nextFormatting = formatting && {
|
|
318
|
+
...formatting,
|
|
319
|
+
...formatting.styleId !== void 0 && { styleId: styleCloner.styleIdFor(formatting.styleId) },
|
|
320
|
+
...formatting.numPr?.numId !== void 0 && formatting.numPr.numId !== 0 && { numPr: {
|
|
321
|
+
...formatting.numPr,
|
|
322
|
+
numId: cloner.cloneNumId(formatting.numPr.numId)
|
|
323
|
+
} },
|
|
324
|
+
...formatting.numPrFromStyle?.numId !== void 0 && formatting.numPrFromStyle.numId !== 0 && { numPrFromStyle: {
|
|
325
|
+
...formatting.numPrFromStyle,
|
|
326
|
+
numId: cloner.cloneNumId(formatting.numPrFromStyle.numId)
|
|
327
|
+
} }
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
...rest,
|
|
331
|
+
content: structuredClone(paragraph.content),
|
|
332
|
+
paraId: targetParaId,
|
|
333
|
+
...nextFormatting && { formatting: nextFormatting },
|
|
334
|
+
...paragraph.listRendering && { listRendering: remapListRendering(paragraph.listRendering, cloner) }
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
const remapListRendering = (rendering, cloner) => {
|
|
338
|
+
const clonedAbstract = rendering.abstractNumId === void 0 ? void 0 : cloner.clonedAbstractNumId(rendering.abstractNumId);
|
|
339
|
+
return {
|
|
340
|
+
...rendering,
|
|
341
|
+
numId: cloner.cloneNumId(rendering.numId),
|
|
342
|
+
...clonedAbstract !== void 0 && { abstractNumId: clonedAbstract }
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
const collectTableParagraphs = (table) => {
|
|
346
|
+
const out = [];
|
|
347
|
+
for (const row of table.rows) for (const cell of row.cells) for (const item of cell.content) if (item.type === "paragraph") out.push(item);
|
|
348
|
+
else out.push(...collectTableParagraphs(item));
|
|
349
|
+
return out;
|
|
350
|
+
};
|
|
351
|
+
const buildRow = (left, right) => ({
|
|
352
|
+
type: "tableRow",
|
|
353
|
+
formatting: { cantSplit: true },
|
|
354
|
+
cells: [buildCell(left), buildCell(right)]
|
|
355
|
+
});
|
|
356
|
+
const buildCell = (paragraph) => ({
|
|
357
|
+
type: "tableCell",
|
|
358
|
+
formatting: {
|
|
359
|
+
width: {
|
|
360
|
+
value: HALF_WIDTH_PCT,
|
|
361
|
+
type: "pct"
|
|
362
|
+
},
|
|
363
|
+
verticalAlign: "top"
|
|
364
|
+
},
|
|
365
|
+
content: [paragraph]
|
|
366
|
+
});
|
|
367
|
+
/** A source table kept once, across both columns. */
|
|
368
|
+
const buildSpanningRow = (table) => ({
|
|
369
|
+
type: "tableRow",
|
|
370
|
+
formatting: { cantSplit: true },
|
|
371
|
+
cells: [{
|
|
372
|
+
type: "tableCell",
|
|
373
|
+
formatting: {
|
|
374
|
+
width: {
|
|
375
|
+
value: FULL_WIDTH_PCT,
|
|
376
|
+
type: "pct"
|
|
377
|
+
},
|
|
378
|
+
gridSpan: 2,
|
|
379
|
+
verticalAlign: "top"
|
|
380
|
+
},
|
|
381
|
+
content: [table, {
|
|
382
|
+
type: "paragraph",
|
|
383
|
+
content: []
|
|
384
|
+
}]
|
|
385
|
+
}]
|
|
386
|
+
});
|
|
387
|
+
const buildTable = (rows, borders, textWidth) => ({
|
|
388
|
+
type: "table",
|
|
389
|
+
formatting: {
|
|
390
|
+
width: {
|
|
391
|
+
value: FULL_WIDTH_PCT,
|
|
392
|
+
type: "pct"
|
|
393
|
+
},
|
|
394
|
+
layout: "fixed",
|
|
395
|
+
borders: TABLE_BORDERS[borders],
|
|
396
|
+
look: {
|
|
397
|
+
firstRow: false,
|
|
398
|
+
firstColumn: false,
|
|
399
|
+
noHBand: true,
|
|
400
|
+
noVBand: true
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
columnWidths: [Math.floor(textWidth / 2), Math.ceil(textWidth / 2)],
|
|
404
|
+
rows
|
|
405
|
+
});
|
|
406
|
+
const resolveTextWidthTwips = (doc) => {
|
|
407
|
+
const section = doc.package.document.finalSectionProperties ?? doc.package.document.sections?.at(0)?.properties;
|
|
408
|
+
if (!section?.pageWidth) return A4_TEXT_WIDTH_TWIPS;
|
|
409
|
+
const width = section.pageWidth - (section.marginLeft ?? 0) - (section.marginRight ?? 0);
|
|
410
|
+
return width > 0 ? width : A4_TEXT_WIDTH_TWIPS;
|
|
411
|
+
};
|
|
412
|
+
/**
|
|
413
|
+
* Every `paraId` anywhere in the package (body, headers, footers, notes,
|
|
414
|
+
* comments), so a minted id cannot collide with a part the body never sees.
|
|
415
|
+
* Walks the model generically: any object with `type: "paragraph"` and a
|
|
416
|
+
* string `paraId` counts.
|
|
417
|
+
*/
|
|
418
|
+
const collectPackageParaIds = (pkg) => {
|
|
419
|
+
const ids = /* @__PURE__ */ new Set();
|
|
420
|
+
const seen = /* @__PURE__ */ new Set();
|
|
421
|
+
const visit = (value) => {
|
|
422
|
+
if (typeof value !== "object" || value === null || seen.has(value)) return;
|
|
423
|
+
seen.add(value);
|
|
424
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return;
|
|
425
|
+
if (Array.isArray(value)) {
|
|
426
|
+
for (const item of value) visit(item);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (value instanceof Map) {
|
|
430
|
+
for (const item of value.values()) visit(item);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (isParagraphWithId(value)) ids.add(value.paraId);
|
|
434
|
+
for (const child of Object.values(value)) visit(child);
|
|
435
|
+
};
|
|
436
|
+
visit(pkg);
|
|
437
|
+
return ids;
|
|
438
|
+
};
|
|
439
|
+
/**
|
|
440
|
+
* Fresh ids derived from the source id (so re-running on the same source
|
|
441
|
+
* yields the same handles), salted past any id already in the document.
|
|
442
|
+
*/
|
|
443
|
+
const createParaIdMinter = (taken) => {
|
|
444
|
+
let ordinal = 0;
|
|
445
|
+
return { mint: (sourceParaId) => {
|
|
446
|
+
ordinal += 1;
|
|
447
|
+
const seed = `${ROW_ID_NAMESPACE}:${sourceParaId ?? `ordinal-${ordinal}`}`;
|
|
448
|
+
let id = deterministicHexId(seed);
|
|
449
|
+
for (let salt = 1; taken.has(id); salt += 1) id = deterministicHexId(`${seed}:${salt}`);
|
|
450
|
+
taken.add(id);
|
|
451
|
+
return id;
|
|
452
|
+
} };
|
|
453
|
+
};
|
|
454
|
+
/**
|
|
455
|
+
* Re-derive the row manifest from a document produced by
|
|
456
|
+
* {@link createBilingualDocument}. Detection is structural: a top-level table
|
|
457
|
+
* whose rows are all either a left | right pair of single-paragraph cells or
|
|
458
|
+
* one cell spanning both columns. Rows are returned in document order; the
|
|
459
|
+
* right paragraph's `paraId` is the row handle, as at creation.
|
|
460
|
+
*/
|
|
461
|
+
function readBilingualDocument(document) {
|
|
462
|
+
const styleById = new Map((document.package.styles?.styles ?? []).map((style) => [style.styleId, style]));
|
|
463
|
+
const rows = [];
|
|
464
|
+
for (const block of flattenBlocks(document.package.document.content)) {
|
|
465
|
+
if (block.type !== "table" || !isBilingualTable(block)) continue;
|
|
466
|
+
for (const row of block.rows) {
|
|
467
|
+
const [left, right] = row.cells;
|
|
468
|
+
if (!left) continue;
|
|
469
|
+
if (!right) {
|
|
470
|
+
const paragraphs = left.content.filter((item) => item.type === "table").flatMap(collectTableParagraphs).map((paragraph) => ({
|
|
471
|
+
paraId: paragraph.paraId,
|
|
472
|
+
sourceText: getParagraphText(paragraph)
|
|
473
|
+
}));
|
|
474
|
+
rows.push({
|
|
475
|
+
kind: "table",
|
|
476
|
+
rowId: paragraphs.at(0)?.paraId ?? tableRowHandle(rows.length),
|
|
477
|
+
paragraphs
|
|
478
|
+
});
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const source = left.content.at(0);
|
|
482
|
+
const target = right.content.at(0);
|
|
483
|
+
if (source?.type !== "paragraph" || target?.type !== "paragraph" || !target.paraId) continue;
|
|
484
|
+
rows.push({
|
|
485
|
+
kind: classifyParagraph(source, styleById),
|
|
486
|
+
rowId: target.paraId,
|
|
487
|
+
sourceParaId: source.paraId,
|
|
488
|
+
targetParaId: target.paraId,
|
|
489
|
+
sourceText: getParagraphText(source)
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return rows;
|
|
494
|
+
}
|
|
495
|
+
const isBilingualTable = (table) => {
|
|
496
|
+
let pairs = 0;
|
|
497
|
+
for (const row of table.rows) {
|
|
498
|
+
const cells = row.cells;
|
|
499
|
+
if (cells.length === 2) {
|
|
500
|
+
if (!cells.every((cell) => cell.content.length === 1 && cell.content[0]?.type === "paragraph")) return false;
|
|
501
|
+
pairs += 1;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (cells.length === 1 && cells[0]?.formatting?.gridSpan === 2) continue;
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
return pairs > 0;
|
|
508
|
+
};
|
|
509
|
+
const isParagraphWithId = (value) => "type" in value && value.type === "paragraph" && "paraId" in value && typeof value.paraId === "string";
|
|
510
|
+
//#endregion
|
|
511
|
+
export { InvalidBilingualDocumentOptionsError, createBilingualDocument, readBilingualDocument };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BilingualRow, CreateBilingualDocumentOptions } from "./createBilingualDocument.js";
|
|
2
|
+
//#region src/docx/server/createBilingualDocx.d.ts
|
|
3
|
+
type CreateBilingualDocxResult = {
|
|
4
|
+
buffer: ArrayBuffer;
|
|
5
|
+
rows: BilingualRow[];
|
|
6
|
+
warnings: string[];
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Bytes-in / bytes-out form of {@link createBilingualDocument}: stamp every
|
|
10
|
+
* paragraph with a `paraId` (so left-column rows are addressable later), parse
|
|
11
|
+
* the DOCX (no font preloading, so it never touches the DOM), lay the body out
|
|
12
|
+
* as a two-column table, and repack onto the original package so theme, fonts,
|
|
13
|
+
* media, headers and footers carry over untouched.
|
|
14
|
+
*/
|
|
15
|
+
declare function createBilingualDocx(input: ArrayBuffer | Uint8Array, options: CreateBilingualDocumentOptions): Promise<CreateBilingualDocxResult>;
|
|
16
|
+
/** Bytes-in form of {@link readBilingualDocument}. */
|
|
17
|
+
declare function readBilingualDocx(input: ArrayBuffer | Uint8Array): Promise<BilingualRow[]>;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { CreateBilingualDocxResult, createBilingualDocx, readBilingualDocx };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ensureParaIds } from "../ensureParaIds.js";
|
|
2
|
+
import { parseDocx } from "../parser.js";
|
|
3
|
+
import { createDocx } from "../rezip.js";
|
|
4
|
+
import { createBilingualDocument, readBilingualDocument } from "./createBilingualDocument.js";
|
|
5
|
+
//#region src/docx/server/createBilingualDocx.ts
|
|
6
|
+
/**
|
|
7
|
+
* Bytes-in / bytes-out form of {@link createBilingualDocument}: stamp every
|
|
8
|
+
* paragraph with a `paraId` (so left-column rows are addressable later), parse
|
|
9
|
+
* the DOCX (no font preloading, so it never touches the DOM), lay the body out
|
|
10
|
+
* as a two-column table, and repack onto the original package so theme, fonts,
|
|
11
|
+
* media, headers and footers carry over untouched.
|
|
12
|
+
*/
|
|
13
|
+
async function createBilingualDocx(input, options) {
|
|
14
|
+
const { document, rows, warnings } = createBilingualDocument(await parseDocx((await ensureParaIds(input)).docx, { preloadFonts: false }), options);
|
|
15
|
+
return {
|
|
16
|
+
buffer: await createDocx(document),
|
|
17
|
+
rows,
|
|
18
|
+
warnings
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Bytes-in form of {@link readBilingualDocument}. */
|
|
22
|
+
async function readBilingualDocx(input) {
|
|
23
|
+
return readBilingualDocument(await parseDocx(input, { preloadFonts: false }));
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
export { createBilingualDocx, readBilingualDocx };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { findChild, findChildren, getAttribute, parseBooleanElement, parseXmlDocument } from "./xmlParser.js";
|
|
1
|
+
import { WORDPROCESSINGML_NAMESPACE_URIS, findChild, findChildByNamespaceUri, findChildren, getAttribute, parseBooleanElement, parseXmlDocument } from "./xmlParser.js";
|
|
2
2
|
//#region src/docx/settingsParser.ts
|
|
3
3
|
/** OOXML default per §17.6.13 when `w:defaultTabStop` is absent. */
|
|
4
4
|
const DEFAULT_TAB_STOP_TWIPS = 720;
|
|
@@ -26,10 +26,13 @@ const MAX_KINSOKU_CHARACTERS_LENGTH = 128;
|
|
|
26
26
|
function parseSettings(xml) {
|
|
27
27
|
const root = xml ? parseXmlDocument(xml) : null;
|
|
28
28
|
const settings = { defaultTabStop: parseDefaultTabStop(root) };
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
const wordprocessingFlag = (localName) => {
|
|
30
|
+
const element = findChildByNamespaceUri(root, WORDPROCESSINGML_NAMESPACE_URIS, localName);
|
|
31
|
+
return element !== null && parseBooleanElement(element);
|
|
32
|
+
};
|
|
33
|
+
if (wordprocessingFlag("evenAndOddHeaders")) settings.evenAndOddHeaders = true;
|
|
34
|
+
if (wordprocessingFlag("mirrorMargins")) settings.mirrorMargins = true;
|
|
35
|
+
if (wordprocessingFlag("updateFields")) settings.updateFields = true;
|
|
33
36
|
const themeFontLangEl = root ? findChild(root, "w", "themeFontLang") : null;
|
|
34
37
|
const eastAsiaLang = themeFontLangEl ? getAttribute(themeFontLangEl, "w", "eastAsia") || void 0 : void 0;
|
|
35
38
|
const bidiLang = themeFontLangEl ? getAttribute(themeFontLangEl, "w", "bidi") || void 0 : void 0;
|
package/dist/docx/xmlParser.d.ts
CHANGED
|
@@ -96,6 +96,14 @@ declare function getLocalName(name: string | undefined): string;
|
|
|
96
96
|
declare function getNamespacePrefix(name: string): string | null;
|
|
97
97
|
/** Namespace URI resolved from the element's in-scope XML declarations. */
|
|
98
98
|
declare const getNamespaceUri: (element: XmlElement) => string | undefined;
|
|
99
|
+
/** WordprocessingML main namespace, Transitional and Strict (ECMA-376 Parts 1 and 4). */
|
|
100
|
+
declare const WORDPROCESSINGML_NAMESPACE_URIS: ReadonlySet<string>;
|
|
101
|
+
/**
|
|
102
|
+
* First child whose local name matches AND whose resolved namespace URI is
|
|
103
|
+
* one of `namespaceUris`. Unlike {@link findChild}, a same-named element
|
|
104
|
+
* from a foreign namespace is not accepted.
|
|
105
|
+
*/
|
|
106
|
+
declare function findChildByNamespaceUri(parent: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): XmlElement | null;
|
|
99
107
|
/** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
|
|
100
108
|
declare function getAttributeByNamespaceUri(element: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): string | null;
|
|
101
109
|
/**
|
|
@@ -326,4 +334,4 @@ declare function mergeXmlnsDeclarations(inherited: Record<string, string>, eleme
|
|
|
326
334
|
*/
|
|
327
335
|
declare function cloneWithXmlnsDeclarations(element: XmlElement, xmlnsDecls: Record<string, string>): XmlElement;
|
|
328
336
|
//#endregion
|
|
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 };
|
|
337
|
+
export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, XmlElement, XmlNamespaceScope, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, 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
|
@@ -196,6 +196,18 @@ function getNamespacePrefix(name) {
|
|
|
196
196
|
}
|
|
197
197
|
/** Namespace URI resolved from the element's in-scope XML declarations. */
|
|
198
198
|
const getNamespaceUri = (element) => element.namespaceUri;
|
|
199
|
+
/** WordprocessingML main namespace, Transitional and Strict (ECMA-376 Parts 1 and 4). */
|
|
200
|
+
const WORDPROCESSINGML_NAMESPACE_URIS = /* @__PURE__ */ new Set([NAMESPACES.w, "http://purl.oclc.org/ooxml/wordprocessingml/main"]);
|
|
201
|
+
/**
|
|
202
|
+
* First child whose local name matches AND whose resolved namespace URI is
|
|
203
|
+
* one of `namespaceUris`. Unlike {@link findChild}, a same-named element
|
|
204
|
+
* from a foreign namespace is not accepted.
|
|
205
|
+
*/
|
|
206
|
+
function findChildByNamespaceUri(parent, namespaceUris, localName) {
|
|
207
|
+
if (!parent?.elements) return null;
|
|
208
|
+
for (const child of parent.elements) if (child.type === "element" && hasLocalName(child.name, localName) && namespaceUris.has(child.namespaceUri ?? "")) return child;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
199
211
|
/** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
|
|
200
212
|
function getAttributeByNamespaceUri(element, namespaceUris, localName) {
|
|
201
213
|
if (!element?.attributes) return null;
|
|
@@ -687,4 +699,4 @@ function cloneWithXmlnsDeclarations(element, xmlnsDecls) {
|
|
|
687
699
|
return element;
|
|
688
700
|
}
|
|
689
701
|
//#endregion
|
|
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 };
|
|
702
|
+
export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
|