rtf-codec 2.0.0 → 3.0.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/write.js CHANGED
@@ -3,6 +3,7 @@ import { RtfDiagnosticCodes, RtfUnsupportedDocumentKindError } from "./diagnosti
3
3
  import { pointsToHalfPoints, pointsToTwips } from "./units.js";
4
4
  import { borderControlWords } from "./cell-format.js";
5
5
  import { bookmarkResidueControlWords, dttmFromIso, isBookmarkAnchor } from "./constructs.js";
6
+ import { writeEmbeddedObjectData } from "./embedded-object.js";
6
7
  import { parseRtfListNumId } from "./list-id.js";
7
8
  import { clampHeadingLevel, colorToRgbHex, flattenTree, resolveCellFillColor } from "document-schema.js";
8
9
  //#region src/write.ts
@@ -13,6 +14,11 @@ const LIST_MARKER_HANG_TWIPS = 360;
13
14
  const BULLET_LEVEL_TEXT = "\\'01\\u183 ?";
14
15
  const ARABIC_LEVEL_TEXT = "\\'02\\'00.";
15
16
  const OUTPUT_CODEPAGE = 1252;
17
+ const CELL_BLOCK_KINDS = /* @__PURE__ */ new Set([
18
+ "paragraph",
19
+ "image",
20
+ "embeddedObject"
21
+ ]);
16
22
  const SECTION_BREAK_CONTROL_WORDS = /* @__PURE__ */ new Map([
17
23
  ["continuous", "\\sbknone"],
18
24
  ["evenPage", "\\sbkeven"],
@@ -124,6 +130,112 @@ function bookmarkStartGroup(descriptor) {
124
130
  if (!isBookmarkAnchor(descriptor)) return "";
125
131
  return `{\\*\\bkmkstart${bookmarkResidueControlWords(descriptor)} ${escapeText(descriptor.name)}}`;
126
132
  }
133
+ const FORM_FIELD_SPEC = /* @__PURE__ */ new Map([
134
+ ["plainText", {
135
+ instruction: "FORMTEXT",
136
+ fftype: 0
137
+ }],
138
+ ["checkbox", {
139
+ instruction: "FORMCHECKBOX",
140
+ fftype: 1
141
+ }],
142
+ ["dropDown", {
143
+ instruction: "FORMDROPDOWN",
144
+ fftype: 2
145
+ }]
146
+ ]);
147
+ const MAX_DROPDOWN_OPTIONS = 25;
148
+ function formFieldPayload(descriptor, fftype, sink) {
149
+ const fftypeFragment = `\\fftype${String(fftype)}`;
150
+ let ffNameString = "";
151
+ let ffDefTextString = "";
152
+ let ffHelpTextString = "";
153
+ let fflEntries = "";
154
+ let ffOwnHelpFragment = "";
155
+ if (descriptor.alias !== void 0 && descriptor.alias.trim().length > 0) {
156
+ ffOwnHelpFragment = "\\ffownhelp1";
157
+ ffHelpTextString = `{\\*\\ffhelptext ${escapeText(descriptor.alias)}}`;
158
+ }
159
+ let ffProtFragment = "";
160
+ if (descriptor.lock === "content" || descriptor.lock === "both") ffProtFragment = "\\ffprot1";
161
+ if (descriptor.lock === "container" || descriptor.lock === "both") {
162
+ const message = descriptor.lock === "both" ? `a contentControl's 'both' lock also protects the control from removal, which RTF's \\ffprot ([MS-DOC] 2.9.79 FFDataBits.fProt) cannot express -- \\ffprot1 above already carries the content-protection half of 'both', so only the container-removal half is dropped here` : `a contentControl's 'container' lock protects the control from removal, which RTF's \\ffprot ([MS-DOC] 2.9.79 FFDataBits.fProt) cannot express at all -- it names only whether the field's own value can be changed, and a 'container' lock leaves that value editable, so nothing is written for it and the whole lock is dropped, not merely half of it`;
163
+ sink({
164
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
165
+ severity: "warning",
166
+ message
167
+ });
168
+ }
169
+ let controlTypeParams = "";
170
+ if (descriptor.controlType === "checkbox") {
171
+ const value = descriptor.checked === true ? "1" : "0";
172
+ controlTypeParams += `\\ffdefres${value}\\ffres${value}`;
173
+ if (descriptor.value !== void 0 && descriptor.value.length > 0) sink({
174
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
175
+ severity: "warning",
176
+ message: `a checkbox contentControl's value '${descriptor.value}' (its on-state export name) is dropped: RTF's \\ffres/\\ffdefres can only carry the field's boolean checked state, with no spelling for a named export value at all`
177
+ });
178
+ if (descriptor.options !== void 0 && descriptor.options.length > 0) sink({
179
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
180
+ severity: "warning",
181
+ message: `a checkbox contentControl's options list (${String(descriptor.options.length)} entries) is dropped: a checkbox has no choice list at all, in RTF or in the harmonised contentControl vocabulary itself`
182
+ });
183
+ } else if (descriptor.controlType === "dropDown") {
184
+ controlTypeParams += "\\ffhaslistbox1";
185
+ const allOptions = descriptor.options;
186
+ let options = allOptions;
187
+ if (allOptions !== void 0 && allOptions.length > MAX_DROPDOWN_OPTIONS) {
188
+ sink({
189
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
190
+ severity: "warning",
191
+ message: `a dropDown contentControl's ${String(allOptions.length)} options exceed [MS-DOC] 2.9.78 FFData.hsttbDropList's own ${String(MAX_DROPDOWN_OPTIONS)}-entry limit; only the first ${String(MAX_DROPDOWN_OPTIONS)} are written`
192
+ });
193
+ options = allOptions.slice(0, MAX_DROPDOWN_OPTIONS);
194
+ }
195
+ const selectedIndex = options === void 0 || descriptor.value === void 0 ? void 0 : options.indexOf(descriptor.value);
196
+ if (selectedIndex !== void 0 && selectedIndex !== -1) controlTypeParams += `\\ffdefres${String(selectedIndex)}\\ffres${String(selectedIndex)}`;
197
+ else if (descriptor.value !== void 0 && descriptor.value.length > 0) {
198
+ const truncatedAway = allOptions !== void 0 && options !== allOptions && allOptions.includes(descriptor.value);
199
+ sink({
200
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
201
+ severity: "warning",
202
+ message: truncatedAway ? `a dropDown contentControl's selected value '${descriptor.value}' is dropped: its matching option was truncated away by [MS-DOC] 2.9.78 FFData.hsttbDropList's own ${String(MAX_DROPDOWN_OPTIONS)}-entry limit, and \\ffres/\\ffdefres can only name a real index into the entries actually written` : `a dropDown contentControl's selected value '${descriptor.value}' is dropped: it does not match any of the field's own options, and \\ffres/\\ffdefres can only name a real index into that list`
203
+ });
204
+ }
205
+ if (options !== void 0) for (const option of options) fflEntries += `{\\*\\ffl ${escapeText(option)}}`;
206
+ if (descriptor.checked !== void 0) sink({
207
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
208
+ severity: "warning",
209
+ message: `a dropDown contentControl's checked state (${String(descriptor.checked)}) is dropped: a dropdown has no boolean checked state at all, in RTF or in the harmonised contentControl vocabulary itself`
210
+ });
211
+ } else if (descriptor.controlType === "plainText") {
212
+ if (descriptor.value !== void 0 && descriptor.value.length > 0) {
213
+ ffDefTextString = `{\\*\\ffdeftext ${escapeText(descriptor.value)}}`;
214
+ sink({
215
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
216
+ severity: "warning",
217
+ message: `a plainText contentControl's value '${descriptor.value}' is written into {\\*\\ffdeftext ...}, FFData.xstzTextDef's default/reset text, not a slot for the field's current value: this codec's own reader does not restore \\ffdeftext back onto \`value\`, so this does not round-trip`
218
+ });
219
+ }
220
+ if (descriptor.checked !== void 0) sink({
221
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
222
+ severity: "warning",
223
+ message: `a plainText contentControl's checked state (${String(descriptor.checked)}) is dropped: a text field has no boolean checked state at all, in RTF or in the harmonised contentControl vocabulary itself`
224
+ });
225
+ if (descriptor.options !== void 0 && descriptor.options.length > 0) sink({
226
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
227
+ severity: "warning",
228
+ message: `a plainText contentControl's options list (${String(descriptor.options.length)} entries) is dropped: a text field has no choice list at all, in RTF or in the harmonised contentControl vocabulary itself`
229
+ });
230
+ }
231
+ if (descriptor.tag !== void 0 && descriptor.tag.trim().length > 0) ffNameString = `{\\*\\ffname ${escapeText(descriptor.tag)}}`;
232
+ return fftypeFragment + ffOwnHelpFragment + ffProtFragment + controlTypeParams + ffNameString + ffDefTextString + ffHelpTextString + fflEntries;
233
+ }
234
+ function formFieldOpenGroup(descriptor, sink) {
235
+ const spec = FORM_FIELD_SPEC.get(descriptor.controlType);
236
+ if (spec === void 0) return;
237
+ return `{\\field{\\*\\fldinst ${spec.instruction} {\\*\\formfield{${formFieldPayload(descriptor, spec.fftype, sink)}}}}{\\fldrslt `;
238
+ }
127
239
  const CHREV_CONTROL_WORDS = {
128
240
  insertion: {
129
241
  flag: "\\revised",
@@ -174,9 +286,35 @@ function verticalMergeCoverage(table) {
174
286
  function nameOf(descriptor) {
175
287
  return isBookmarkAnchor(descriptor) ? descriptor.name : "";
176
288
  }
289
+ function isContentControlExtent(extent) {
290
+ return extent.descriptor.kind === "contentControl";
291
+ }
292
+ function selectNestableFormFields(extents, sink) {
293
+ const sorted = [...extents].sort((a, b) => a.startRun - b.startRun || b.endRun - a.endRun);
294
+ const stack = [];
295
+ const accepted = [];
296
+ for (const extent of sorted) {
297
+ let top = stack[stack.length - 1];
298
+ while (top !== void 0 && top.endRun <= extent.startRun) {
299
+ stack.pop();
300
+ top = stack[stack.length - 1];
301
+ }
302
+ if (top !== void 0 && extent.endRun > top.endRun) {
303
+ sink({
304
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
305
+ severity: "warning",
306
+ message: "a contentControl construct is dropped: it crosses another contentControl extent in the same paragraph (starts before that extent ends but ends after it too), and RTF's \\*\\formfield destination can only nest properly, never cross"
307
+ });
308
+ continue;
309
+ }
310
+ stack.push(extent);
311
+ accepted.push(extent);
312
+ }
313
+ return accepted;
314
+ }
177
315
  function describeConstructGap(descriptor) {
178
316
  switch (descriptor.kind) {
179
- case "contentControl": return "structured-document-tag equivalent; its own \\*\\formfield production is a narrower construct this writer does not yet mint";
317
+ case "contentControl": return "block-scoped structured-document-tag equivalent -- a run-scoped plainText/checkbox/dropDown form field mints its own \\*\\formfield instead; any other controlType (richText, comboBox, date, and the rest) has no \\*\\formfield spelling at all";
180
318
  case "provenance": return "block-scoped revision mark: its <chrev> production is a character property, so a tracked change reaches RTF only as a run-level extent";
181
319
  case "anchor": return `spelling for a '${descriptor.anchorType}' anchor, whose body would need the note or annotation destination this reader does not place`;
182
320
  case "field": return "block-scoped field: a field is a character-stream construct, written from a run's own hyperlink rather than from a block marker";
@@ -184,6 +322,9 @@ function describeConstructGap(descriptor) {
184
322
  default: return "equivalent construct";
185
323
  }
186
324
  }
325
+ function describeFormFieldGap(descriptor) {
326
+ return `\\*\\formfield spelling for a '${descriptor.controlType}' controlType -- only plainText/checkbox/dropDown form fields mint one`;
327
+ }
187
328
  var RtfWriter = class {
188
329
  tables;
189
330
  sink;
@@ -300,11 +441,7 @@ var RtfWriter = class {
300
441
  this.line("\\page\\pard");
301
442
  return;
302
443
  case "embeddedObject":
303
- this.sink({
304
- code: RtfDiagnosticCodes.EMBEDDED_OBJECT_DROPPED,
305
- severity: "warning",
306
- message: `an embedded ${block.objectKind} object is dropped: writing it as an RTF \\object would need the OLE container this package does not build`
307
- });
444
+ this.writeEmbeddedObjectBlock(block);
308
445
  return;
309
446
  case "constructStart":
310
447
  this.openConstruct(block.descriptor);
@@ -339,13 +476,22 @@ var RtfWriter = class {
339
476
  this.raw(" ");
340
477
  const bookmarks = (paragraph.constructs ?? []).filter((extent) => isBookmarkAnchor(extent.descriptor));
341
478
  const revisions = (paragraph.constructs ?? []).filter((extent) => extent.descriptor.kind === "provenance");
479
+ const formFields = selectNestableFormFields((paragraph.constructs ?? []).filter(isContentControlExtent), this.sink);
480
+ const openedFormFields = [];
342
481
  for (const [index, run] of paragraph.runs.entries()) {
343
482
  this.writeRunBoundaries(bookmarks, index);
483
+ this.writeFormFieldBoundaries(formFields, index, openedFormFields);
344
484
  this.writeRun(run, revisionsCovering(revisions, index));
345
485
  }
346
486
  this.writeRunBoundaries(bookmarks, paragraph.runs.length);
487
+ this.writeFormFieldBoundaries(formFields, paragraph.runs.length, openedFormFields);
488
+ this.drainOpenedFormFields(openedFormFields);
347
489
  if (!inTable) this.line("\\par");
348
490
  }
491
+ drainOpenedFormFields(opened) {
492
+ for (let remaining = opened.length; remaining > 0; remaining -= 1) this.raw("}}");
493
+ opened.length = 0;
494
+ }
349
495
  writeRunBoundaries(extents, position) {
350
496
  for (const extent of extents) if (extent.endRun === position && extent.startRun !== position) this.raw(`{\\*\\bkmkend ${escapeText(nameOf(extent.descriptor))}}`);
351
497
  for (const extent of extents) if (extent.startRun === position) {
@@ -353,6 +499,32 @@ var RtfWriter = class {
353
499
  if (extent.endRun === position) this.raw(`{\\*\\bkmkend ${escapeText(nameOf(extent.descriptor))}}`);
354
500
  }
355
501
  }
502
+ writeFormFieldBoundaries(extents, position, opened) {
503
+ let top = opened[opened.length - 1];
504
+ while (top?.endRun === position) {
505
+ opened.pop();
506
+ this.raw("}}");
507
+ top = opened[opened.length - 1];
508
+ }
509
+ for (const extent of extents) {
510
+ if (extent.startRun !== position) continue;
511
+ const open = formFieldOpenGroup(extent.descriptor, this.sink);
512
+ if (open === void 0) {
513
+ this.sink({
514
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
515
+ severity: "warning",
516
+ message: `a contentControl construct is dropped: RTF has no ${describeFormFieldGap(extent.descriptor)}`
517
+ });
518
+ continue;
519
+ }
520
+ this.raw(open);
521
+ opened.push(extent);
522
+ if (extent.endRun === position) {
523
+ opened.pop();
524
+ this.raw("}}");
525
+ }
526
+ }
527
+ }
356
528
  paragraphProperties(paragraph) {
357
529
  let out = "";
358
530
  const level = paragraph.headingLevel === void 0 ? void 0 : clampHeadingLevel(paragraph.headingLevel);
@@ -466,17 +638,48 @@ var RtfWriter = class {
466
638
  return `${out}\\cellx${String(rightTwips)}`;
467
639
  }
468
640
  writeCellBlocks(blocks) {
469
- const paragraphs = blocks.filter((block) => block.kind === "paragraph");
470
- if (paragraphs.length === 0) {
471
- this.raw("\\pard\\plain\\intbl ");
472
- return;
473
- }
474
- for (const [index, paragraph] of paragraphs.entries()) {
475
- this.writeParagraph(paragraph, true);
476
- if (index < paragraphs.length - 1) this.raw("\\par");
641
+ let wroteBlock = false;
642
+ let blockPending = false;
643
+ for (const [index, block] of blocks.entries()) {
644
+ if (block.kind === "constructStart" || block.kind === "constructEnd") {
645
+ if (blockPending && blocks.slice(index + 1).some((later) => CELL_BLOCK_KINDS.has(later.kind))) {
646
+ this.raw("\\par");
647
+ blockPending = false;
648
+ }
649
+ if (block.kind === "constructStart") this.openConstruct(block.descriptor);
650
+ else this.closeConstruct();
651
+ continue;
652
+ }
653
+ if (!CELL_BLOCK_KINDS.has(block.kind)) {
654
+ this.sink({
655
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
656
+ severity: "warning",
657
+ message: `a ${block.kind} block inside a table cell is dropped: this writer cannot yet splice a ${block.kind}'s own destination grammar into a table row's own \\intbl flow`
658
+ });
659
+ continue;
660
+ }
661
+ if (block.kind === "image") {
662
+ const decoded = this.decodeImageOrWarn(block);
663
+ if (decoded === void 0) continue;
664
+ if (blockPending) this.raw("\\par");
665
+ this.writeImagePict(decoded, block, true);
666
+ wroteBlock = true;
667
+ blockPending = false;
668
+ continue;
669
+ }
670
+ if (blockPending) this.raw("\\par");
671
+ if (block.kind === "paragraph") {
672
+ this.writeParagraph(block, true);
673
+ blockPending = true;
674
+ } else if (block.kind === "embeddedObject") {
675
+ this.writeEmbeddedObjectBlock(block, true);
676
+ blockPending = false;
677
+ }
678
+ wroteBlock = true;
477
679
  }
680
+ if (!wroteBlock) this.raw("\\pard\\plain\\intbl ");
478
681
  }
479
- writeImageParagraph(base64, image) {
682
+ decodeImageOrWarn(image) {
480
683
  if (image.format === "svg" || image.format === "gif") {
481
684
  this.sink({
482
685
  code: RtfDiagnosticCodes.UNSUPPORTED_PICTURE_FORMAT,
@@ -485,7 +688,7 @@ var RtfWriter = class {
485
688
  });
486
689
  return;
487
690
  }
488
- const bytes = base64ToBytes(base64);
691
+ const bytes = base64ToBytes(image.base64);
489
692
  if (bytes === void 0 || bytes.length === 0) {
490
693
  this.sink({
491
694
  code: RtfDiagnosticCodes.UNSUPPORTED_PICTURE_FORMAT,
@@ -494,9 +697,30 @@ var RtfWriter = class {
494
697
  });
495
698
  return;
496
699
  }
700
+ return bytes;
701
+ }
702
+ writeImagePict(bytes, image, inTable) {
497
703
  const widthTwips = pointsToTwips(image.widthPt);
498
704
  const heightTwips = pointsToTwips(image.heightPt);
499
- this.line(`\\pard\\plain {\\*\\shppict{\\pict\\${image.format === "png" ? "pngblip" : "jpegblip"}\\picwgoal${String(widthTwips)}\\pichgoal${String(heightTwips)}${this.lineEnding}${wrapHex(bytesToHex(bytes), this.lineEnding)}}}\\par`);
705
+ const pict = `\\pard\\plain${inTable ? "\\intbl" : ""} {\\*\\shppict{\\pict\\${image.format === "png" ? "pngblip" : "jpegblip"}\\picwgoal${String(widthTwips)}\\pichgoal${String(heightTwips)}${this.lineEnding}${wrapHex(bytesToHex(bytes), this.lineEnding)}}}`;
706
+ if (inTable) this.raw(pict);
707
+ else this.line(`${pict}\\par`);
708
+ }
709
+ writeImageParagraph(base64, image) {
710
+ const bytes = this.decodeImageOrWarn({
711
+ ...image,
712
+ base64
713
+ });
714
+ if (bytes === void 0) return;
715
+ this.writeImagePict(bytes, image, false);
716
+ }
717
+ writeEmbeddedObjectBlock(block, inTable = false) {
718
+ const widthTwips = pointsToTwips(block.frame.widthPt);
719
+ const heightTwips = pointsToTwips(block.frame.heightPt);
720
+ const objdataBytes = writeEmbeddedObjectData(block);
721
+ const object = `\\pard\\plain${inTable ? "\\intbl" : ""} {\\object\\objemb\\objw${String(widthTwips)}\\objh${String(heightTwips)}{\\*\\objclass ${escapeText(block.objectKind)}}{\\*\\objdata${this.lineEnding}${wrapHex(bytesToHex(objdataBytes), this.lineEnding)}}{\\result{\\pard\\plain ${escapeText(`[embedded ${block.objectKind} object]`)}\\par}}}`;
722
+ if (inTable) this.raw(object);
723
+ else this.line(`${object}\\par`);
500
724
  }
501
725
  };
502
726
  function colorIndexOf(color, colors) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rtf-codec",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Hand-written Rich Text Format (RTF 1.9.1) reader and writer against the shared document-schema.js content pivot.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -62,6 +62,8 @@
62
62
  "test:watch": "vitest --project unit",
63
63
  "test:coverage": "turbo run _test:coverage",
64
64
  "_test:coverage": "vitest run --project unit --coverage",
65
+ "test:mutation": "turbo run _test:mutation",
66
+ "_test:mutation": "stryker run stryker.config.mjs",
65
67
  "test:smoke": "turbo run _test:smoke",
66
68
  "_test:smoke": "vitest run --project smoke",
67
69
  "prepare": "husky",
@@ -78,16 +80,21 @@
78
80
  "license": "MIT",
79
81
  "packageManager": "pnpm@11.6.0",
80
82
  "dependencies": {
81
- "document-schema.js": "^6.0.0",
83
+ "archive-codec": "^1.6.0",
84
+ "document-schema.js": "^6.1.0",
82
85
  "zod": "^4.4.3"
83
86
  },
84
87
  "devDependencies": {
85
88
  "@arethetypeswrong/cli": "^0.18.5",
86
89
  "@cloudflare/vitest-pool-workers": "^0.21.2",
90
+ "@stryker-mutator/core": "^10.0.0",
91
+ "@stryker-mutator/typescript-checker": "^10.0.0",
92
+ "@stryker-mutator/vitest-runner": "^10.0.0",
87
93
  "@types/node": "^26.1.1",
88
94
  "@vitest/coverage-v8": "^4.1.10",
89
95
  "eslint": "^10.8.0",
90
96
  "husky": "^9.1.7",
97
+ "jiti": "2.7.0",
91
98
  "publint": "^0.3.21",
92
99
  "semantic-release": "^25.0.8",
93
100
  "tsdown": "^0.22.13",