doc-codec 1.1.2 → 2.1.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.
@@ -1,5 +1,6 @@
1
1
  import { DocFormatError, DocUnsupportedError } from "../errors.js";
2
2
  import "../text/special.js";
3
+ import { fitsAloneOnPapxPage } from "../prop/fkp-write.js";
3
4
  import { encodeTableRowGrpprl } from "./tap-write.js";
4
5
  //#region src/table/write.ts
5
6
  /** sprmPFInTable (0x2416): a Bool8, "MUST be 1 any time the table depth is greater than zero". */
@@ -47,7 +48,64 @@ function columnBoundariesTwips(columnWidthsPt) {
47
48
  }
48
49
  return boundaries;
49
50
  }
50
- function flattenRow(cells, columnCount, boundaries, active) {
51
+ /** A deep copy of `active` -- a fresh Map holding a fresh object per entry, never the same ActiveVerticalMerge instances. flattenTable's own per-row budget check (see its own note below) has to try flattening a row's lost-boundary split as a dry run before committing to it, and placeCell mutates `covered.remaining` on the object a Map entry already holds -- a shallow copy would let that dry run's own mutation bleed into the real, committed state for every row after it. */
52
+ function cloneActive(active) {
53
+ return new Map(Array.from(active, ([column, merge]) => [column, { ...merge }]));
54
+ }
55
+ /** Where one logical cell lands on the row's own grid, before any lost-boundary splitting: its span in grid columns, and whether it is a vertical-merge continuation of a cell above. `active`'s own cross-row state must evolve identically wherever this runs, since recoverableBoundaries and flattenRow each walk every row with their own fresh map and have to land on the same columns for the second pass's splitting decisions to mean anything. */
56
+ function placeCell(cell, column, active) {
57
+ const covered = active.get(column);
58
+ if (cell.blocks.length === 0 && covered !== void 0 && covered.remaining > 0) {
59
+ covered.remaining -= 1;
60
+ return {
61
+ span: covered.span,
62
+ isContinuation: true
63
+ };
64
+ }
65
+ const span = cell.colSpan ?? 1;
66
+ const rowSpan = cell.rowSpan ?? 1;
67
+ if (rowSpan > 1) active.set(column, {
68
+ span,
69
+ remaining: rowSpan - 1
70
+ });
71
+ return {
72
+ span,
73
+ isContinuation: false
74
+ };
75
+ }
76
+ function recoverableBoundaries(rows) {
77
+ const stated = /* @__PURE__ */ new Set();
78
+ const active = /* @__PURE__ */ new Map();
79
+ for (const row of rows) {
80
+ let column = 0;
81
+ for (const cell of row.cells) {
82
+ const { span } = placeCell(cell, column, active);
83
+ column += span;
84
+ stated.add(column);
85
+ }
86
+ }
87
+ return stated;
88
+ }
89
+ function distributeLostBoundaries(lostBoundaries, rowCount) {
90
+ const perRow = Array.from({ length: rowCount }, () => /* @__PURE__ */ new Set());
91
+ lostBoundaries.forEach((boundary, index) => {
92
+ const bucket = perRow[index % rowCount];
93
+ if (bucket === void 0) throw new DocFormatError("internal defect: distributeLostBoundaries built fewer row buckets than the row count it was given");
94
+ bucket.add(boundary);
95
+ });
96
+ return perRow;
97
+ }
98
+ function splitAtLostBoundaries(column, span, lostBoundaries) {
99
+ const subSpans = [];
100
+ let start = column;
101
+ for (let position = column + 1; position < column + span; position += 1) if (lostBoundaries.has(position)) {
102
+ subSpans.push(position - start);
103
+ start = position;
104
+ }
105
+ subSpans.push(column + span - start);
106
+ return subSpans;
107
+ }
108
+ function flattenRow(cells, columnCount, boundaries, active, lostBoundaries) {
51
109
  const paragraphs = [];
52
110
  const cellsToWrite = [];
53
111
  const firstBoundary = boundaries[0];
@@ -55,36 +113,31 @@ function flattenRow(cells, columnCount, boundaries, active) {
55
113
  const rowBoundariesTwips = [firstBoundary];
56
114
  let column = 0;
57
115
  for (const cell of cells) {
58
- const covered = active.get(column);
59
- const isContinuation = cell.blocks.length === 0 && covered !== void 0 && covered.remaining > 0;
60
- let span;
61
- let vertMerge;
62
- let blocks;
63
- if (isContinuation) {
64
- covered.remaining -= 1;
65
- span = covered.span;
66
- vertMerge = 1;
67
- blocks = [];
68
- } else {
69
- span = cell.colSpan ?? 1;
70
- const rowSpan = cell.rowSpan ?? 1;
71
- vertMerge = rowSpan > 1 ? 3 : 0;
72
- if (rowSpan > 1) active.set(column, {
73
- span,
74
- remaining: rowSpan - 1
116
+ const startColumn = column;
117
+ const { span, isContinuation } = placeCell(cell, startColumn, active);
118
+ const rowSpan = cell.rowSpan ?? 1;
119
+ const vertMerge = isContinuation ? 1 : rowSpan > 1 ? 3 : 0;
120
+ const blocks = isContinuation ? [] : cell.blocks;
121
+ const subSpans = splitAtLostBoundaries(startColumn, span, lostBoundaries);
122
+ subSpans.forEach((subSpan, subIndex) => {
123
+ paragraphs.push(...cellParagraphs(subIndex === 0 ? blocks : []));
124
+ cellsToWrite.push(subIndex === 0 ? isContinuation ? {
125
+ vertMerge,
126
+ horzMerge: subSpans.length > 1 ? 2 : 0
127
+ } : {
128
+ vertMerge,
129
+ horzMerge: subSpans.length > 1 ? 2 : 0,
130
+ borders: cell.borders,
131
+ background: cell.background
132
+ } : {
133
+ vertMerge,
134
+ horzMerge: 1
75
135
  });
76
- blocks = cell.blocks;
77
- }
78
- paragraphs.push(...cellParagraphs(blocks));
79
- cellsToWrite.push(isContinuation ? { vertMerge } : {
80
- vertMerge,
81
- borders: cell.borders,
82
- background: cell.background
136
+ column += subSpan;
137
+ const rightBoundary = boundaries[column];
138
+ if (rightBoundary === void 0) throw new DocFormatError(`a table cell's own colSpan runs past the table's ${columnCount}-column grid`);
139
+ rowBoundariesTwips.push(rightBoundary);
83
140
  });
84
- column += span;
85
- const rightBoundary = boundaries[column];
86
- if (rightBoundary === void 0) throw new DocFormatError(`a table cell's own colSpan runs past the table's ${columnCount}-column grid`);
87
- rowBoundariesTwips.push(rightBoundary);
88
141
  }
89
142
  if (column !== columnCount) throw new DocFormatError(`a table row's own cells cover ${column} columns (via colSpan), but the table declares ${columnCount} in columnWidthsPt`);
90
143
  return {
@@ -93,27 +146,51 @@ function flattenRow(cells, columnCount, boundaries, active) {
93
146
  rowBoundariesTwips
94
147
  };
95
148
  }
96
- function flattenTable(table) {
149
+ function rowMarkParagraph(rowBoundariesTwips, cellsToWrite, heightPt) {
150
+ return {
151
+ runs: [],
152
+ properties: {},
153
+ extraGrpprl: rowMarkExtraGrpprl(rowBoundariesTwips, cellsToWrite, heightPt),
154
+ terminator: 7
155
+ };
156
+ }
157
+ function rowSplitFits(row, columnCount, boundaries, active, candidateBoundaries, heightPt) {
158
+ const trial = flattenRow(row.cells, columnCount, boundaries, cloneActive(active), candidateBoundaries);
159
+ if (trial.cellsToWrite.length > 63) return false;
160
+ const trialGrpprl = rowMarkExtraGrpprl(trial.rowBoundariesTwips, trial.cellsToWrite, heightPt);
161
+ return fitsAloneOnPapxPage(trialGrpprl);
162
+ }
163
+ function flattenTable(table, blockIndex, onWarning) {
97
164
  const columnCount = table.columnWidthsPt.length;
98
165
  if (columnCount === 0 || table.rows.length === 0) throw new DocFormatError("a table must have at least one column and one row to write");
99
166
  const boundaries = columnBoundariesTwips(table.columnWidthsPt);
167
+ const stated = recoverableBoundaries(table.rows);
168
+ const lostBoundaries = [];
169
+ for (let index = 1; index < columnCount; index += 1) if (!stated.has(index)) lostBoundaries.push(index);
170
+ const lostBoundariesByRow = distributeLostBoundaries(lostBoundaries, table.rows.length);
100
171
  const active = /* @__PURE__ */ new Map();
101
172
  const output = [];
102
- for (const row of table.rows) {
103
- const { paragraphs, cellsToWrite, rowBoundariesTwips } = flattenRow(row.cells, columnCount, boundaries, active);
173
+ table.rows.forEach((row, rowIndex) => {
174
+ const rowLostBoundaries = lostBoundariesByRow[rowIndex];
175
+ if (rowLostBoundaries === void 0) throw new DocFormatError("internal defect: distributeLostBoundaries returned fewer buckets than the table has rows");
176
+ let rowLostBoundariesToApply = rowLostBoundaries;
177
+ if (rowLostBoundaries.size > 0 && !rowSplitFits(row, columnCount, boundaries, active, rowLostBoundaries, row.heightPt)) {
178
+ const ordered = Array.from(rowLostBoundaries);
179
+ let kept = ordered;
180
+ while (kept.length > 0 && !rowSplitFits(row, columnCount, boundaries, active, new Set(kept), row.heightPt)) kept = kept.slice(0, -1);
181
+ rowLostBoundariesToApply = new Set(kept);
182
+ const droppedCount = ordered.length - kept.length;
183
+ onWarning?.(kept.length === 0 ? `doc-codec: table at block ${blockIndex}, row ${rowIndex} could not state ${rowLostBoundaries.size === 1 ? "its assigned lost column boundary" : `any of its ${rowLostBoundaries.size} assigned lost column boundaries`} without exceeding a PapxInFkp record's own byte budget or the format's own 63-cell-per-row ceiling; attempting to write it unsplit instead, which narrows columnWidthsPt on read for this table exactly as this writer's own pre-#992 behaviour did -- if this row's own unsplit encoding also overflows this same budget, writeDocContent can still throw its usual DocFormatError further down this same pipeline, after any remaining rows have reported their own warnings` : `doc-codec: table at block ${blockIndex}, row ${rowIndex} could only state ${kept.length} of its ${ordered.length} assigned lost column boundaries without exceeding a PapxInFkp record's own byte budget or the format's own 63-cell-per-row ceiling; dropping the other ${droppedCount} (narrowing columnWidthsPt on read for those boundaries alone)`);
184
+ }
185
+ const { paragraphs, cellsToWrite, rowBoundariesTwips } = flattenRow(row.cells, columnCount, boundaries, active, rowLostBoundariesToApply);
104
186
  output.push(...paragraphs);
105
- output.push({
106
- runs: [],
107
- properties: {},
108
- extraGrpprl: rowMarkExtraGrpprl(rowBoundariesTwips, cellsToWrite, row.heightPt),
109
- terminator: 7
110
- });
111
- }
187
+ output.push(rowMarkParagraph(rowBoundariesTwips, cellsToWrite, row.heightPt));
188
+ });
112
189
  return output;
113
190
  }
114
- function flattenSectionBlocks(blocks) {
191
+ function flattenSectionBlocks(blocks, onWarning) {
115
192
  const output = [];
116
- for (const block of blocks) {
193
+ blocks.forEach((block, blockIndex) => {
117
194
  if (block.kind === "paragraph") {
118
195
  output.push({
119
196
  runs: block.runs,
@@ -121,14 +198,14 @@ function flattenSectionBlocks(blocks) {
121
198
  extraGrpprl: [],
122
199
  terminator: 13
123
200
  });
124
- continue;
201
+ return;
125
202
  }
126
203
  if (block.kind === "table") {
127
- output.push(...flattenTable(block));
128
- continue;
204
+ output.push(...flattenTable(block, blockIndex, onWarning));
205
+ return;
129
206
  }
130
207
  throw new DocUnsupportedError(`doc-codec's writer does not yet support '${block.kind}' blocks (see README's scope note)`);
131
- }
208
+ });
132
209
  return output;
133
210
  }
134
211
  //#endregion
@@ -0,0 +1,15 @@
1
+ import { ContentBlock, ContentParagraph, ContentRun } from "document-schema.js";
2
+ //#region src/table/write.d.ts
3
+ /** Reports a non-fatal write-time degradation -- this package's own analogue of byte-codec's/pdf-codec's `onWarning`, adopted here rather than a new shape of its own so a caller already handling one already handles the other. */
4
+ type WriteWarning = (message: string) => void;
5
+ interface WriteParagraph {
6
+ readonly runs: readonly ContentRun[];
7
+ readonly properties: Pick<ContentParagraph, "alignment" | "indentLeftPt" | "indentFirstLinePt" | "spacingBeforePt" | "spacingAfterPt" | "lineSpacing" | "pageBreakBefore">;
8
+ /** Extra grpprl bytes appended after encodeParagraphGrpprl's own output -- sprmPFInTable on every table paragraph, plus sprmPFTtp and the row's own TAP on a row's trailing mark. */
9
+ readonly extraGrpprl: readonly number[];
10
+ /** The character terminating this paragraph in the text stream: PARAGRAPH_MARK normally, CELL_MARK for a table cell or row mark. */
11
+ readonly terminator: number;
12
+ }
13
+ declare function flattenSectionBlocks(blocks: readonly ContentBlock[], onWarning?: WriteWarning): WriteParagraph[];
14
+ //#endregion
15
+ export { WriteWarning as n, flattenSectionBlocks as r, WriteParagraph as t };
@@ -0,0 +1,15 @@
1
+ import { ContentBlock, ContentParagraph, ContentRun } from "document-schema.js";
2
+ //#region src/table/write.d.ts
3
+ /** Reports a non-fatal write-time degradation -- this package's own analogue of byte-codec's/pdf-codec's `onWarning`, adopted here rather than a new shape of its own so a caller already handling one already handles the other. */
4
+ type WriteWarning = (message: string) => void;
5
+ interface WriteParagraph {
6
+ readonly runs: readonly ContentRun[];
7
+ readonly properties: Pick<ContentParagraph, "alignment" | "indentLeftPt" | "indentFirstLinePt" | "spacingBeforePt" | "spacingAfterPt" | "lineSpacing" | "pageBreakBefore">;
8
+ /** Extra grpprl bytes appended after encodeParagraphGrpprl's own output -- sprmPFInTable on every table paragraph, plus sprmPFTtp and the row's own TAP on a row's trailing mark. */
9
+ readonly extraGrpprl: readonly number[];
10
+ /** The character terminating this paragraph in the text stream: PARAGRAPH_MARK normally, CELL_MARK for a table cell or row mark. */
11
+ readonly terminator: number;
12
+ }
13
+ declare function flattenSectionBlocks(blocks: readonly ContentBlock[], onWarning?: WriteWarning): WriteParagraph[];
14
+ //#endregion
15
+ export { WriteWarning as n, flattenSectionBlocks as r, WriteParagraph as t };
package/dist/write.cjs CHANGED
@@ -19,12 +19,12 @@ let archive_codec = require("archive-codec");
19
19
  const TEXT_FC = 1024;
20
20
  /** This writer only ever emits 16-bit (uncompressed) text -- see text/piece-table-write.ts. */
21
21
  const BYTES_PER_CHARACTER = 2;
22
- function writeDocContent(document) {
22
+ function writeDocContent(document, options = {}) {
23
23
  if (document.kind !== "wordprocessing") throw new require_errors.DocUnsupportedError(`doc-codec writes wordprocessing documents only; got a '${document.kind}' document`);
24
24
  if (document.sections.length !== 1) throw new require_errors.DocUnsupportedError(`doc-codec's reader never distinguishes more than one section within a document (see README's "Section properties" scope note): writeDocContent refuses ${document.sections.length} sections rather than silently merging their content into what would read back as one`);
25
25
  const [section] = document.sections;
26
26
  if (section === void 0) throw new require_errors.DocFormatError("a wordprocessing document must carry a section");
27
- const writeParagraphs = require_table_write.flattenSectionBlocks(section.blocks);
27
+ const writeParagraphs = require_table_write.flattenSectionBlocks(section.blocks, options.onWarning);
28
28
  if (writeParagraphs[writeParagraphs.length - 1]?.terminator !== 13) writeParagraphs.push({
29
29
  runs: [],
30
30
  properties: {},
package/dist/write.d.cts CHANGED
@@ -1,5 +1,10 @@
1
+ import { n as WriteWarning } from "./write-C_vJizAM.cjs";
1
2
  import { ContentDocument } from "document-schema.js";
2
3
  //#region src/write.d.ts
3
- declare function writeDocContent(document: ContentDocument): Uint8Array<ArrayBuffer>;
4
+ interface WriteDocContentOptions {
5
+ /** Reports a non-fatal write-time degradation -- today, only table/write.ts's own per-row lost-boundary-budget fallback (ExaDev/documents.js#1013), the same `onWarning` shape byte-codec's PNG decoder and pdf-codec already use for a recoverable, non-fatal defect. Not a guarantee the write itself goes on to succeed: when a row's own assigned lost boundaries can't be trimmed down to a split that fits at all, this still fires once -- reporting that the row's boundaries could not be stated and that its fully-unsplit encoding is being attempted instead -- before writeDocContent can discover, further down the same pipeline, that even that unsplit encoding overflows the row's own byte budget and throws its usual DocFormatError; the warning describes what this fallback could not recover, not a promise that a hard failure won't immediately follow it. It is never called in place of a genuine refusal this writer makes outright (an unsupported block kind, more than one section, and so on) -- those always throw DocFormatError/DocUnsupportedError directly, with no warning first. */
6
+ readonly onWarning?: WriteWarning;
7
+ }
8
+ declare function writeDocContent(document: ContentDocument, options?: WriteDocContentOptions): Uint8Array<ArrayBuffer>;
4
9
  //#endregion
5
- export { writeDocContent };
10
+ export { WriteDocContentOptions, writeDocContent };
package/dist/write.d.ts CHANGED
@@ -1,5 +1,10 @@
1
+ import { n as WriteWarning } from "./write-C_vJizAM.js";
1
2
  import { ContentDocument } from "document-schema.js";
2
3
  //#region src/write.d.ts
3
- declare function writeDocContent(document: ContentDocument): Uint8Array<ArrayBuffer>;
4
+ interface WriteDocContentOptions {
5
+ /** Reports a non-fatal write-time degradation -- today, only table/write.ts's own per-row lost-boundary-budget fallback (ExaDev/documents.js#1013), the same `onWarning` shape byte-codec's PNG decoder and pdf-codec already use for a recoverable, non-fatal defect. Not a guarantee the write itself goes on to succeed: when a row's own assigned lost boundaries can't be trimmed down to a split that fits at all, this still fires once -- reporting that the row's boundaries could not be stated and that its fully-unsplit encoding is being attempted instead -- before writeDocContent can discover, further down the same pipeline, that even that unsplit encoding overflows the row's own byte budget and throws its usual DocFormatError; the warning describes what this fallback could not recover, not a promise that a hard failure won't immediately follow it. It is never called in place of a genuine refusal this writer makes outright (an unsupported block kind, more than one section, and so on) -- those always throw DocFormatError/DocUnsupportedError directly, with no warning first. */
6
+ readonly onWarning?: WriteWarning;
7
+ }
8
+ declare function writeDocContent(document: ContentDocument, options?: WriteDocContentOptions): Uint8Array<ArrayBuffer>;
4
9
  //#endregion
5
- export { writeDocContent };
10
+ export { WriteDocContentOptions, writeDocContent };
package/dist/write.js CHANGED
@@ -18,12 +18,12 @@ import { hasSummaryInformationFields, writeCompoundFile, writeSummaryInformation
18
18
  const TEXT_FC = 1024;
19
19
  /** This writer only ever emits 16-bit (uncompressed) text -- see text/piece-table-write.ts. */
20
20
  const BYTES_PER_CHARACTER = 2;
21
- function writeDocContent(document) {
21
+ function writeDocContent(document, options = {}) {
22
22
  if (document.kind !== "wordprocessing") throw new DocUnsupportedError(`doc-codec writes wordprocessing documents only; got a '${document.kind}' document`);
23
23
  if (document.sections.length !== 1) throw new DocUnsupportedError(`doc-codec's reader never distinguishes more than one section within a document (see README's "Section properties" scope note): writeDocContent refuses ${document.sections.length} sections rather than silently merging their content into what would read back as one`);
24
24
  const [section] = document.sections;
25
25
  if (section === void 0) throw new DocFormatError("a wordprocessing document must carry a section");
26
- const writeParagraphs = flattenSectionBlocks(section.blocks);
26
+ const writeParagraphs = flattenSectionBlocks(section.blocks, options.onWarning);
27
27
  if (writeParagraphs[writeParagraphs.length - 1]?.terminator !== 13) writeParagraphs.push({
28
28
  runs: [],
29
29
  properties: {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc-codec",
3
- "version": "1.1.2",
3
+ "version": "2.1.0",
4
4
  "description": "A hand-written reader for the Word Binary File Format ([MS-DOC], .doc) against the shared document-schema.js content pivot: FIB parsing, piece-table text reconstruction, and CHPX/PAPX formatting exceptions.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -58,6 +58,8 @@
58
58
  "test": "turbo run _test",
59
59
  "_test": "vitest run --project unit",
60
60
  "_test:coverage": "vitest run --project unit --coverage",
61
+ "test:mutation": "turbo run _test:mutation",
62
+ "_test:mutation": "stryker run stryker.config.mjs",
61
63
  "test:watch": "vitest --project unit",
62
64
  "test:workers": "turbo run _test:workers",
63
65
  "_test:workers": "vitest run --config vitest.workers.config.ts",
@@ -76,15 +78,19 @@
76
78
  "license": "MIT",
77
79
  "packageManager": "pnpm@11.6.0",
78
80
  "dependencies": {
79
- "archive-codec": "^1.4.2",
80
- "document-schema.js": "^5.6.0"
81
+ "archive-codec": "^1.5.0",
82
+ "document-schema.js": "^6.1.0"
81
83
  },
82
84
  "devDependencies": {
83
85
  "@arethetypeswrong/cli": "^0.18.5",
84
86
  "@cloudflare/vitest-pool-workers": "^0.20.1",
87
+ "@stryker-mutator/core": "^10.0.0",
88
+ "@stryker-mutator/typescript-checker": "^10.0.0",
89
+ "@stryker-mutator/vitest-runner": "^10.0.0",
85
90
  "@types/node": "^26.1.2",
86
91
  "eslint": "^10.8.0",
87
92
  "husky": "^9.1.7",
93
+ "jiti": "2.7.0",
88
94
  "publint": "^0.3.21",
89
95
  "tsdown": "^0.22.13",
90
96
  "turbo": "^2.10.8",