@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.
@@ -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 evenAndOddHeaders = root ? findChild(root, "w", "evenAndOddHeaders") : null;
30
- if (evenAndOddHeaders && parseBooleanElement(evenAndOddHeaders)) settings.evenAndOddHeaders = true;
31
- const mirrorMargins = root ? findChild(root, "w", "mirrorMargins") : null;
32
- if (mirrorMargins && parseBooleanElement(mirrorMargins)) settings.mirrorMargins = true;
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;
@@ -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 };
@@ -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 };