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.cjs CHANGED
@@ -4,6 +4,7 @@ const require_diagnostics = require("./diagnostics.cjs");
4
4
  const require_units = require("./units.cjs");
5
5
  const require_cell_format = require("./cell-format.cjs");
6
6
  const require_constructs = require("./constructs.cjs");
7
+ const require_embedded_object = require("./embedded-object.cjs");
7
8
  const require_list_id = require("./list-id.cjs");
8
9
  let document_schema_js = require("document-schema.js");
9
10
  //#region src/write.ts
@@ -14,6 +15,11 @@ const LIST_MARKER_HANG_TWIPS = 360;
14
15
  const BULLET_LEVEL_TEXT = "\\'01\\u183 ?";
15
16
  const ARABIC_LEVEL_TEXT = "\\'02\\'00.";
16
17
  const OUTPUT_CODEPAGE = 1252;
18
+ const CELL_BLOCK_KINDS = /* @__PURE__ */ new Set([
19
+ "paragraph",
20
+ "image",
21
+ "embeddedObject"
22
+ ]);
17
23
  const SECTION_BREAK_CONTROL_WORDS = /* @__PURE__ */ new Map([
18
24
  ["continuous", "\\sbknone"],
19
25
  ["evenPage", "\\sbkeven"],
@@ -125,6 +131,112 @@ function bookmarkStartGroup(descriptor) {
125
131
  if (!require_constructs.isBookmarkAnchor(descriptor)) return "";
126
132
  return `{\\*\\bkmkstart${require_constructs.bookmarkResidueControlWords(descriptor)} ${escapeText(descriptor.name)}}`;
127
133
  }
134
+ const FORM_FIELD_SPEC = /* @__PURE__ */ new Map([
135
+ ["plainText", {
136
+ instruction: "FORMTEXT",
137
+ fftype: 0
138
+ }],
139
+ ["checkbox", {
140
+ instruction: "FORMCHECKBOX",
141
+ fftype: 1
142
+ }],
143
+ ["dropDown", {
144
+ instruction: "FORMDROPDOWN",
145
+ fftype: 2
146
+ }]
147
+ ]);
148
+ const MAX_DROPDOWN_OPTIONS = 25;
149
+ function formFieldPayload(descriptor, fftype, sink) {
150
+ const fftypeFragment = `\\fftype${String(fftype)}`;
151
+ let ffNameString = "";
152
+ let ffDefTextString = "";
153
+ let ffHelpTextString = "";
154
+ let fflEntries = "";
155
+ let ffOwnHelpFragment = "";
156
+ if (descriptor.alias !== void 0 && descriptor.alias.trim().length > 0) {
157
+ ffOwnHelpFragment = "\\ffownhelp1";
158
+ ffHelpTextString = `{\\*\\ffhelptext ${escapeText(descriptor.alias)}}`;
159
+ }
160
+ let ffProtFragment = "";
161
+ if (descriptor.lock === "content" || descriptor.lock === "both") ffProtFragment = "\\ffprot1";
162
+ if (descriptor.lock === "container" || descriptor.lock === "both") {
163
+ 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`;
164
+ sink({
165
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
166
+ severity: "warning",
167
+ message
168
+ });
169
+ }
170
+ let controlTypeParams = "";
171
+ if (descriptor.controlType === "checkbox") {
172
+ const value = descriptor.checked === true ? "1" : "0";
173
+ controlTypeParams += `\\ffdefres${value}\\ffres${value}`;
174
+ if (descriptor.value !== void 0 && descriptor.value.length > 0) sink({
175
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
176
+ severity: "warning",
177
+ 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`
178
+ });
179
+ if (descriptor.options !== void 0 && descriptor.options.length > 0) sink({
180
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
181
+ severity: "warning",
182
+ 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`
183
+ });
184
+ } else if (descriptor.controlType === "dropDown") {
185
+ controlTypeParams += "\\ffhaslistbox1";
186
+ const allOptions = descriptor.options;
187
+ let options = allOptions;
188
+ if (allOptions !== void 0 && allOptions.length > MAX_DROPDOWN_OPTIONS) {
189
+ sink({
190
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
191
+ severity: "warning",
192
+ 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`
193
+ });
194
+ options = allOptions.slice(0, MAX_DROPDOWN_OPTIONS);
195
+ }
196
+ const selectedIndex = options === void 0 || descriptor.value === void 0 ? void 0 : options.indexOf(descriptor.value);
197
+ if (selectedIndex !== void 0 && selectedIndex !== -1) controlTypeParams += `\\ffdefres${String(selectedIndex)}\\ffres${String(selectedIndex)}`;
198
+ else if (descriptor.value !== void 0 && descriptor.value.length > 0) {
199
+ const truncatedAway = allOptions !== void 0 && options !== allOptions && allOptions.includes(descriptor.value);
200
+ sink({
201
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
202
+ severity: "warning",
203
+ 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`
204
+ });
205
+ }
206
+ if (options !== void 0) for (const option of options) fflEntries += `{\\*\\ffl ${escapeText(option)}}`;
207
+ if (descriptor.checked !== void 0) sink({
208
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
209
+ severity: "warning",
210
+ 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`
211
+ });
212
+ } else if (descriptor.controlType === "plainText") {
213
+ if (descriptor.value !== void 0 && descriptor.value.length > 0) {
214
+ ffDefTextString = `{\\*\\ffdeftext ${escapeText(descriptor.value)}}`;
215
+ sink({
216
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
217
+ severity: "warning",
218
+ 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`
219
+ });
220
+ }
221
+ if (descriptor.checked !== void 0) sink({
222
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
223
+ severity: "warning",
224
+ 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`
225
+ });
226
+ if (descriptor.options !== void 0 && descriptor.options.length > 0) sink({
227
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
228
+ severity: "warning",
229
+ 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`
230
+ });
231
+ }
232
+ if (descriptor.tag !== void 0 && descriptor.tag.trim().length > 0) ffNameString = `{\\*\\ffname ${escapeText(descriptor.tag)}}`;
233
+ return fftypeFragment + ffOwnHelpFragment + ffProtFragment + controlTypeParams + ffNameString + ffDefTextString + ffHelpTextString + fflEntries;
234
+ }
235
+ function formFieldOpenGroup(descriptor, sink) {
236
+ const spec = FORM_FIELD_SPEC.get(descriptor.controlType);
237
+ if (spec === void 0) return;
238
+ return `{\\field{\\*\\fldinst ${spec.instruction} {\\*\\formfield{${formFieldPayload(descriptor, spec.fftype, sink)}}}}{\\fldrslt `;
239
+ }
128
240
  const CHREV_CONTROL_WORDS = {
129
241
  insertion: {
130
242
  flag: "\\revised",
@@ -175,9 +287,35 @@ function verticalMergeCoverage(table) {
175
287
  function nameOf(descriptor) {
176
288
  return require_constructs.isBookmarkAnchor(descriptor) ? descriptor.name : "";
177
289
  }
290
+ function isContentControlExtent(extent) {
291
+ return extent.descriptor.kind === "contentControl";
292
+ }
293
+ function selectNestableFormFields(extents, sink) {
294
+ const sorted = [...extents].sort((a, b) => a.startRun - b.startRun || b.endRun - a.endRun);
295
+ const stack = [];
296
+ const accepted = [];
297
+ for (const extent of sorted) {
298
+ let top = stack[stack.length - 1];
299
+ while (top !== void 0 && top.endRun <= extent.startRun) {
300
+ stack.pop();
301
+ top = stack[stack.length - 1];
302
+ }
303
+ if (top !== void 0 && extent.endRun > top.endRun) {
304
+ sink({
305
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
306
+ severity: "warning",
307
+ 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"
308
+ });
309
+ continue;
310
+ }
311
+ stack.push(extent);
312
+ accepted.push(extent);
313
+ }
314
+ return accepted;
315
+ }
178
316
  function describeConstructGap(descriptor) {
179
317
  switch (descriptor.kind) {
180
- case "contentControl": return "structured-document-tag equivalent; its own \\*\\formfield production is a narrower construct this writer does not yet mint";
318
+ 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";
181
319
  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";
182
320
  case "anchor": return `spelling for a '${descriptor.anchorType}' anchor, whose body would need the note or annotation destination this reader does not place`;
183
321
  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";
@@ -185,6 +323,9 @@ function describeConstructGap(descriptor) {
185
323
  default: return "equivalent construct";
186
324
  }
187
325
  }
326
+ function describeFormFieldGap(descriptor) {
327
+ return `\\*\\formfield spelling for a '${descriptor.controlType}' controlType -- only plainText/checkbox/dropDown form fields mint one`;
328
+ }
188
329
  var RtfWriter = class {
189
330
  tables;
190
331
  sink;
@@ -301,11 +442,7 @@ var RtfWriter = class {
301
442
  this.line("\\page\\pard");
302
443
  return;
303
444
  case "embeddedObject":
304
- this.sink({
305
- code: require_diagnostics.RtfDiagnosticCodes.EMBEDDED_OBJECT_DROPPED,
306
- severity: "warning",
307
- message: `an embedded ${block.objectKind} object is dropped: writing it as an RTF \\object would need the OLE container this package does not build`
308
- });
445
+ this.writeEmbeddedObjectBlock(block);
309
446
  return;
310
447
  case "constructStart":
311
448
  this.openConstruct(block.descriptor);
@@ -340,13 +477,22 @@ var RtfWriter = class {
340
477
  this.raw(" ");
341
478
  const bookmarks = (paragraph.constructs ?? []).filter((extent) => require_constructs.isBookmarkAnchor(extent.descriptor));
342
479
  const revisions = (paragraph.constructs ?? []).filter((extent) => extent.descriptor.kind === "provenance");
480
+ const formFields = selectNestableFormFields((paragraph.constructs ?? []).filter(isContentControlExtent), this.sink);
481
+ const openedFormFields = [];
343
482
  for (const [index, run] of paragraph.runs.entries()) {
344
483
  this.writeRunBoundaries(bookmarks, index);
484
+ this.writeFormFieldBoundaries(formFields, index, openedFormFields);
345
485
  this.writeRun(run, revisionsCovering(revisions, index));
346
486
  }
347
487
  this.writeRunBoundaries(bookmarks, paragraph.runs.length);
488
+ this.writeFormFieldBoundaries(formFields, paragraph.runs.length, openedFormFields);
489
+ this.drainOpenedFormFields(openedFormFields);
348
490
  if (!inTable) this.line("\\par");
349
491
  }
492
+ drainOpenedFormFields(opened) {
493
+ for (let remaining = opened.length; remaining > 0; remaining -= 1) this.raw("}}");
494
+ opened.length = 0;
495
+ }
350
496
  writeRunBoundaries(extents, position) {
351
497
  for (const extent of extents) if (extent.endRun === position && extent.startRun !== position) this.raw(`{\\*\\bkmkend ${escapeText(nameOf(extent.descriptor))}}`);
352
498
  for (const extent of extents) if (extent.startRun === position) {
@@ -354,6 +500,32 @@ var RtfWriter = class {
354
500
  if (extent.endRun === position) this.raw(`{\\*\\bkmkend ${escapeText(nameOf(extent.descriptor))}}`);
355
501
  }
356
502
  }
503
+ writeFormFieldBoundaries(extents, position, opened) {
504
+ let top = opened[opened.length - 1];
505
+ while (top?.endRun === position) {
506
+ opened.pop();
507
+ this.raw("}}");
508
+ top = opened[opened.length - 1];
509
+ }
510
+ for (const extent of extents) {
511
+ if (extent.startRun !== position) continue;
512
+ const open = formFieldOpenGroup(extent.descriptor, this.sink);
513
+ if (open === void 0) {
514
+ this.sink({
515
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
516
+ severity: "warning",
517
+ message: `a contentControl construct is dropped: RTF has no ${describeFormFieldGap(extent.descriptor)}`
518
+ });
519
+ continue;
520
+ }
521
+ this.raw(open);
522
+ opened.push(extent);
523
+ if (extent.endRun === position) {
524
+ opened.pop();
525
+ this.raw("}}");
526
+ }
527
+ }
528
+ }
357
529
  paragraphProperties(paragraph) {
358
530
  let out = "";
359
531
  const level = paragraph.headingLevel === void 0 ? void 0 : (0, document_schema_js.clampHeadingLevel)(paragraph.headingLevel);
@@ -467,17 +639,48 @@ var RtfWriter = class {
467
639
  return `${out}\\cellx${String(rightTwips)}`;
468
640
  }
469
641
  writeCellBlocks(blocks) {
470
- const paragraphs = blocks.filter((block) => block.kind === "paragraph");
471
- if (paragraphs.length === 0) {
472
- this.raw("\\pard\\plain\\intbl ");
473
- return;
474
- }
475
- for (const [index, paragraph] of paragraphs.entries()) {
476
- this.writeParagraph(paragraph, true);
477
- if (index < paragraphs.length - 1) this.raw("\\par");
642
+ let wroteBlock = false;
643
+ let blockPending = false;
644
+ for (const [index, block] of blocks.entries()) {
645
+ if (block.kind === "constructStart" || block.kind === "constructEnd") {
646
+ if (blockPending && blocks.slice(index + 1).some((later) => CELL_BLOCK_KINDS.has(later.kind))) {
647
+ this.raw("\\par");
648
+ blockPending = false;
649
+ }
650
+ if (block.kind === "constructStart") this.openConstruct(block.descriptor);
651
+ else this.closeConstruct();
652
+ continue;
653
+ }
654
+ if (!CELL_BLOCK_KINDS.has(block.kind)) {
655
+ this.sink({
656
+ code: require_diagnostics.RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
657
+ severity: "warning",
658
+ 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`
659
+ });
660
+ continue;
661
+ }
662
+ if (block.kind === "image") {
663
+ const decoded = this.decodeImageOrWarn(block);
664
+ if (decoded === void 0) continue;
665
+ if (blockPending) this.raw("\\par");
666
+ this.writeImagePict(decoded, block, true);
667
+ wroteBlock = true;
668
+ blockPending = false;
669
+ continue;
670
+ }
671
+ if (blockPending) this.raw("\\par");
672
+ if (block.kind === "paragraph") {
673
+ this.writeParagraph(block, true);
674
+ blockPending = true;
675
+ } else if (block.kind === "embeddedObject") {
676
+ this.writeEmbeddedObjectBlock(block, true);
677
+ blockPending = false;
678
+ }
679
+ wroteBlock = true;
478
680
  }
681
+ if (!wroteBlock) this.raw("\\pard\\plain\\intbl ");
479
682
  }
480
- writeImageParagraph(base64, image) {
683
+ decodeImageOrWarn(image) {
481
684
  if (image.format === "svg" || image.format === "gif") {
482
685
  this.sink({
483
686
  code: require_diagnostics.RtfDiagnosticCodes.UNSUPPORTED_PICTURE_FORMAT,
@@ -486,7 +689,7 @@ var RtfWriter = class {
486
689
  });
487
690
  return;
488
691
  }
489
- const bytes = require_base64.base64ToBytes(base64);
692
+ const bytes = require_base64.base64ToBytes(image.base64);
490
693
  if (bytes === void 0 || bytes.length === 0) {
491
694
  this.sink({
492
695
  code: require_diagnostics.RtfDiagnosticCodes.UNSUPPORTED_PICTURE_FORMAT,
@@ -495,9 +698,30 @@ var RtfWriter = class {
495
698
  });
496
699
  return;
497
700
  }
701
+ return bytes;
702
+ }
703
+ writeImagePict(bytes, image, inTable) {
498
704
  const widthTwips = require_units.pointsToTwips(image.widthPt);
499
705
  const heightTwips = require_units.pointsToTwips(image.heightPt);
500
- this.line(`\\pard\\plain {\\*\\shppict{\\pict\\${image.format === "png" ? "pngblip" : "jpegblip"}\\picwgoal${String(widthTwips)}\\pichgoal${String(heightTwips)}${this.lineEnding}${wrapHex(require_base64.bytesToHex(bytes), this.lineEnding)}}}\\par`);
706
+ const pict = `\\pard\\plain${inTable ? "\\intbl" : ""} {\\*\\shppict{\\pict\\${image.format === "png" ? "pngblip" : "jpegblip"}\\picwgoal${String(widthTwips)}\\pichgoal${String(heightTwips)}${this.lineEnding}${wrapHex(require_base64.bytesToHex(bytes), this.lineEnding)}}}`;
707
+ if (inTable) this.raw(pict);
708
+ else this.line(`${pict}\\par`);
709
+ }
710
+ writeImageParagraph(base64, image) {
711
+ const bytes = this.decodeImageOrWarn({
712
+ ...image,
713
+ base64
714
+ });
715
+ if (bytes === void 0) return;
716
+ this.writeImagePict(bytes, image, false);
717
+ }
718
+ writeEmbeddedObjectBlock(block, inTable = false) {
719
+ const widthTwips = require_units.pointsToTwips(block.frame.widthPt);
720
+ const heightTwips = require_units.pointsToTwips(block.frame.heightPt);
721
+ const objdataBytes = require_embedded_object.writeEmbeddedObjectData(block);
722
+ const object = `\\pard\\plain${inTable ? "\\intbl" : ""} {\\object\\objemb\\objw${String(widthTwips)}\\objh${String(heightTwips)}{\\*\\objclass ${escapeText(block.objectKind)}}{\\*\\objdata${this.lineEnding}${wrapHex(require_base64.bytesToHex(objdataBytes), this.lineEnding)}}{\\result{\\pard\\plain ${escapeText(`[embedded ${block.objectKind} object]`)}\\par}}}`;
723
+ if (inTable) this.raw(object);
724
+ else this.line(`${object}\\par`);
501
725
  }
502
726
  };
503
727
  function colorIndexOf(color, colors) {