rtf-codec 1.0.0 → 1.1.1

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
@@ -1,6 +1,8 @@
1
1
  import { base64ToBytes, bytesToHex } from "./base64.js";
2
2
  import { RtfDiagnosticCodes, RtfUnsupportedDocumentKindError } from "./diagnostics.js";
3
3
  import { pointsToHalfPoints, pointsToTwips } from "./units.js";
4
+ import { borderControlWords } from "./cell-format.js";
5
+ import { bookmarkResidueControlWords, dttmFromIso, isBookmarkAnchor } from "./constructs.js";
4
6
  import { parseRtfListNumId } from "./list-id.js";
5
7
  import { clampHeadingLevel, colorToRgbHex, flattenTree } from "document-schema.js";
6
8
  //#region src/write.ts
@@ -11,28 +13,47 @@ const LIST_MARKER_HANG_TWIPS = 360;
11
13
  const BULLET_LEVEL_TEXT = "\\'01\\u183 ?";
12
14
  const ARABIC_LEVEL_TEXT = "\\'02\\'00.";
13
15
  const OUTPUT_CODEPAGE = 1252;
16
+ const SECTION_BREAK_CONTROL_WORDS = /* @__PURE__ */ new Map([
17
+ ["continuous", "\\sbknone"],
18
+ ["evenPage", "\\sbkeven"],
19
+ ["oddPage", "\\sbkodd"]
20
+ ]);
14
21
  const ALIGNMENT_CONTROL_WORDS = /* @__PURE__ */ new Map([
15
22
  ["left", "\\ql"],
16
23
  ["center", "\\qc"],
17
24
  ["right", "\\qr"],
18
25
  ["justify", "\\qj"]
19
26
  ]);
27
+ const UNKNOWN_REVISION_AUTHOR = "Unknown";
20
28
  const DEFAULT_FONT_NAME = "Times New Roman";
21
29
  function collectTables(document) {
22
30
  const fonts = /* @__PURE__ */ new Map([[DEFAULT_FONT_NAME, 0]]);
23
31
  const colors = /* @__PURE__ */ new Map();
24
32
  const headingStyles = /* @__PURE__ */ new Map();
25
33
  const lists = /* @__PURE__ */ new Map();
34
+ const revisionAuthors = /* @__PURE__ */ new Map([[UNKNOWN_REVISION_AUTHOR, 0]]);
35
+ const noteDescriptor = (descriptor) => {
36
+ if (descriptor.kind !== "provenance") return;
37
+ const author = descriptor.author;
38
+ if (author !== void 0 && !revisionAuthors.has(author)) revisionAuthors.set(author, revisionAuthors.size);
39
+ };
40
+ const noteColor = (color) => {
41
+ if (color === void 0) return;
42
+ const hex = colorToRgbHex(color);
43
+ if (!colors.has(hex)) colors.set(hex, colors.size + 1);
44
+ };
26
45
  const noteRun = (run) => {
27
46
  if (run.fontFamily !== void 0 && !fonts.has(run.fontFamily)) fonts.set(run.fontFamily, fonts.size);
28
- if (run.color !== void 0) {
29
- const hex = colorToRgbHex(run.color);
30
- if (!colors.has(hex)) colors.set(hex, colors.size + 1);
31
- }
47
+ noteColor(run.color);
32
48
  };
33
49
  const noteBlock = (block) => {
50
+ if (block.kind === "constructStart") {
51
+ noteDescriptor(block.descriptor);
52
+ return;
53
+ }
34
54
  if (block.kind === "paragraph") {
35
55
  for (const run of block.runs) noteRun(run);
56
+ for (const extent of block.constructs ?? []) noteDescriptor(extent.descriptor);
36
57
  if (block.headingLevel !== void 0) {
37
58
  const level = clampHeadingLevel(block.headingLevel);
38
59
  if (!headingStyles.has(level)) headingStyles.set(level, level);
@@ -50,14 +71,19 @@ function collectTables(document) {
50
71
  }
51
72
  return;
52
73
  }
53
- if (block.kind === "table") for (const row of block.rows) for (const cell of row.cells) for (const inner of cell.blocks) noteBlock(inner);
74
+ if (block.kind === "table") for (const row of block.rows) for (const cell of row.cells) {
75
+ noteColor(cell.background);
76
+ for (const side of CELL_BORDER_ORDER) noteColor(cell.borders?.[side]?.color);
77
+ for (const inner of cell.blocks) noteBlock(inner);
78
+ }
54
79
  };
55
80
  if (document.kind === "wordprocessing") for (const section of document.sections) for (const block of section.blocks) noteBlock(block);
56
81
  return {
57
82
  fonts,
58
83
  colors,
59
84
  headingStyles,
60
- lists
85
+ lists,
86
+ revisionAuthors
61
87
  };
62
88
  }
63
89
  function escapeText(text) {
@@ -94,11 +120,76 @@ function escapeText(text) {
94
120
  }
95
121
  return out;
96
122
  }
123
+ function bookmarkStartGroup(descriptor) {
124
+ if (!isBookmarkAnchor(descriptor)) return "";
125
+ return `{\\*\\bkmkstart${bookmarkResidueControlWords(descriptor)} ${escapeText(descriptor.name)}}`;
126
+ }
127
+ const CHREV_CONTROL_WORDS = {
128
+ insertion: {
129
+ flag: "\\revised",
130
+ author: "revauth",
131
+ date: "revdttm"
132
+ },
133
+ deletion: {
134
+ flag: "\\deleted",
135
+ author: "revauthdel",
136
+ date: "revdttmdel"
137
+ },
138
+ moveFrom: {
139
+ flag: "\\mvf",
140
+ author: "mvauth",
141
+ date: "mvdate"
142
+ },
143
+ moveTo: {
144
+ flag: "\\mvt",
145
+ author: "mvauth",
146
+ date: "mvdate"
147
+ },
148
+ formatChange: {
149
+ flag: "",
150
+ author: "crauth",
151
+ date: "crdate"
152
+ }
153
+ };
154
+ function revisionsCovering(extents, index) {
155
+ return extents.filter((extent) => extent.descriptor.kind === "provenance" && extent.startRun <= index && index < extent.endRun).map((extent) => extent.descriptor).filter((descriptor) => descriptor.kind === "provenance");
156
+ }
157
+ const CELL_BORDER_ORDER = [
158
+ "top",
159
+ "left",
160
+ "bottom",
161
+ "right"
162
+ ];
163
+ function verticalMergeCoverage(table) {
164
+ const covered = table.rows.map((row) => row.cells.map(() => false));
165
+ for (const [rowIndex, row] of table.rows.entries()) for (const [cellIndex, cell] of row.cells.entries()) {
166
+ const rowSpan = cell.rowSpan ?? 1;
167
+ for (let next = 1; next < rowSpan; next += 1) {
168
+ const target = covered[rowIndex + next];
169
+ if (target !== void 0 && cellIndex < target.length) target[cellIndex] = true;
170
+ }
171
+ }
172
+ return covered;
173
+ }
174
+ function nameOf(descriptor) {
175
+ return isBookmarkAnchor(descriptor) ? descriptor.name : "";
176
+ }
177
+ function describeConstructGap(descriptor) {
178
+ 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";
180
+ 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
+ case "anchor": return `spelling for a '${descriptor.anchorType}' anchor, whose body would need the note or annotation destination this reader does not place`;
182
+ 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";
183
+ case "link": return "block-scoped link; an external target rides ContentRun.hyperlink instead";
184
+ default: return "equivalent construct";
185
+ }
186
+ }
97
187
  var RtfWriter = class {
98
188
  tables;
99
189
  sink;
100
190
  lineEnding;
101
191
  out = "";
192
+ openConstructs = [];
102
193
  constructor(tables, sink, lineEnding) {
103
194
  this.tables = tables;
104
195
  this.sink = sink;
@@ -115,10 +206,12 @@ var RtfWriter = class {
115
206
  }
116
207
  writeHeader(document) {
117
208
  this.raw(`{\\rtf1\\ansi\\ansicpg${String(OUTPUT_CODEPAGE)}\\deff0\\uc1`);
209
+ this.writeDocumentGeometry(document);
118
210
  this.writeFontTable();
119
211
  this.writeColorTable();
120
212
  this.writeStyleSheet();
121
213
  this.writeListTables();
214
+ this.writeRevisionTable();
122
215
  this.writeInfoGroup(document);
123
216
  this.line("");
124
217
  }
@@ -164,6 +257,12 @@ var RtfWriter = class {
164
257
  for (const entry of entries) this.raw(`{\\listoverride\\listid${String(1e3 + entry.index)}\\listoverridecount0\\ls${String(entry.index)}}`);
165
258
  this.raw("}");
166
259
  }
260
+ writeRevisionTable() {
261
+ if (this.tables.revisionAuthors.size <= 1) return;
262
+ this.raw("{\\*\\revtbl");
263
+ for (const [author] of [...this.tables.revisionAuthors].sort((left, right) => left[1] - right[1])) this.raw(`{${escapeText(author)};}`);
264
+ this.raw("}");
265
+ }
167
266
  writeInfoGroup(document) {
168
267
  const { title, author, subject, keywords } = document.metadata;
169
268
  const fields = [];
@@ -173,9 +272,14 @@ var RtfWriter = class {
173
272
  if (keywords !== void 0 && keywords.length > 0) fields.push(`{\\keywords ${escapeText(keywords.join("; "))}}`);
174
273
  if (fields.length > 0) this.raw(`{\\info${fields.join("")}}`);
175
274
  }
275
+ writeDocumentGeometry(document) {
276
+ const first = document.sections[0];
277
+ if (first === void 0) return;
278
+ this.raw(`\\paperw${String(pointsToTwips(first.pageSize.widthPt))}\\paperh${String(pointsToTwips(first.pageSize.heightPt))}\\margl${String(pointsToTwips(first.margins.leftPt))}\\margr${String(pointsToTwips(first.margins.rightPt))}\\margt${String(pointsToTwips(first.margins.topPt))}\\margb${String(pointsToTwips(first.margins.bottomPt))}`);
279
+ }
176
280
  writeSection(section, isFirst) {
177
281
  if (!isFirst) this.line("\\sect");
178
- this.line(`\\sectd\\paperw${String(pointsToTwips(section.pageSize.widthPt))}\\paperh${String(pointsToTwips(section.pageSize.heightPt))}\\margl${String(pointsToTwips(section.margins.leftPt))}\\margr${String(pointsToTwips(section.margins.rightPt))}\\margt${String(pointsToTwips(section.margins.topPt))}\\margb${String(pointsToTwips(section.margins.bottomPt))}`);
282
+ this.line(`\\sectd${SECTION_BREAK_CONTROL_WORDS.get(section.breakType ?? "") ?? ""}\\pgwsxn${String(pointsToTwips(section.pageSize.widthPt))}\\pghsxn${String(pointsToTwips(section.pageSize.heightPt))}\\marglsxn${String(pointsToTwips(section.margins.leftPt))}\\margrsxn${String(pointsToTwips(section.margins.rightPt))}\\margtsxn${String(pointsToTwips(section.margins.topPt))}\\margbsxn${String(pointsToTwips(section.margins.bottomPt))}`);
179
283
  this.writeBlocks(section.blocks);
180
284
  }
181
285
  writeBlocks(blocks) {
@@ -203,24 +307,52 @@ var RtfWriter = class {
203
307
  });
204
308
  return;
205
309
  case "constructStart":
310
+ this.openConstruct(block.descriptor);
311
+ return;
206
312
  case "constructEnd":
207
- this.sink({
208
- code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
209
- severity: "warning",
210
- message: `a ${block.kind} boundary marker is dropped; this writer emits no RTF construct for the fidelity-construct vocabulary`
211
- });
313
+ this.closeConstruct();
212
314
  return;
213
315
  default: return;
214
316
  }
215
317
  }
318
+ openConstruct(descriptor) {
319
+ if (!isBookmarkAnchor(descriptor)) {
320
+ this.sink({
321
+ code: RtfDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
322
+ severity: "warning",
323
+ message: `a ${descriptor.kind} construct is dropped: RTF has no ${describeConstructGap(descriptor)}`
324
+ });
325
+ this.openConstructs.push(void 0);
326
+ return;
327
+ }
328
+ this.openConstructs.push(descriptor.name);
329
+ this.line(bookmarkStartGroup(descriptor));
330
+ }
331
+ closeConstruct() {
332
+ const name = this.openConstructs.pop();
333
+ if (name !== void 0) this.line(`{\\*\\bkmkend ${escapeText(name)}}`);
334
+ }
216
335
  writeParagraph(paragraph, inTable) {
217
336
  this.raw("\\pard\\plain");
218
337
  if (inTable) this.raw("\\intbl");
219
338
  this.raw(this.paragraphProperties(paragraph));
220
339
  this.raw(" ");
221
- for (const run of paragraph.runs) this.writeRun(run);
340
+ const bookmarks = (paragraph.constructs ?? []).filter((extent) => isBookmarkAnchor(extent.descriptor));
341
+ const revisions = (paragraph.constructs ?? []).filter((extent) => extent.descriptor.kind === "provenance");
342
+ for (const [index, run] of paragraph.runs.entries()) {
343
+ this.writeRunBoundaries(bookmarks, index);
344
+ this.writeRun(run, revisionsCovering(revisions, index));
345
+ }
346
+ this.writeRunBoundaries(bookmarks, paragraph.runs.length);
222
347
  if (!inTable) this.line("\\par");
223
348
  }
349
+ writeRunBoundaries(extents, position) {
350
+ for (const extent of extents) if (extent.endRun === position && extent.startRun !== position) this.raw(`{\\*\\bkmkend ${escapeText(nameOf(extent.descriptor))}}`);
351
+ for (const extent of extents) if (extent.startRun === position) {
352
+ this.raw(bookmarkStartGroup(extent.descriptor));
353
+ if (extent.endRun === position) this.raw(`{\\*\\bkmkend ${escapeText(nameOf(extent.descriptor))}}`);
354
+ }
355
+ }
224
356
  paragraphProperties(paragraph) {
225
357
  let out = "";
226
358
  const level = paragraph.headingLevel === void 0 ? void 0 : clampHeadingLevel(paragraph.headingLevel);
@@ -250,8 +382,8 @@ var RtfWriter = class {
250
382
  if (paragraph.pageBreakBefore === true) out += "\\pagebb";
251
383
  return out;
252
384
  }
253
- writeRun(run) {
254
- const properties = this.runProperties(run);
385
+ writeRun(run, revisions = []) {
386
+ const properties = this.runProperties(run) + this.revisionProperties(revisions);
255
387
  const body = `${properties}${properties.length > 0 ? " " : ""}${escapeText(run.text)}`;
256
388
  if (run.hyperlink === void 0) {
257
389
  this.raw(`{${body}}`);
@@ -259,6 +391,19 @@ var RtfWriter = class {
259
391
  }
260
392
  this.raw(`{\\field{\\*\\fldinst{HYPERLINK "${escapeText(run.hyperlink)}"}}{\\fldrslt{${body}}}}`);
261
393
  }
394
+ revisionProperties(revisions) {
395
+ let out = "";
396
+ for (const descriptor of revisions) {
397
+ const author = descriptor.author;
398
+ const authorIndex = author === void 0 ? void 0 : this.tables.revisionAuthors.get(author);
399
+ const dttm = descriptor.dateIso === void 0 ? void 0 : dttmFromIso(descriptor.dateIso);
400
+ const words = CHREV_CONTROL_WORDS[descriptor.change];
401
+ out += words.flag;
402
+ if (authorIndex !== void 0) out += `\\${words.author}${String(authorIndex)}`;
403
+ if (dttm !== void 0) out += `\\${words.date}${String(dttm)}`;
404
+ }
405
+ return out;
406
+ }
262
407
  runProperties(run) {
263
408
  let out = "";
264
409
  const fontIndex = run.fontFamily === void 0 ? void 0 : this.tables.fonts.get(run.fontFamily);
@@ -274,28 +419,51 @@ var RtfWriter = class {
274
419
  return out;
275
420
  }
276
421
  writeTable(table) {
277
- for (const row of table.rows) {
422
+ const covered = verticalMergeCoverage(table);
423
+ for (const [rowIndex, row] of table.rows.entries()) {
278
424
  let right = 0;
279
- const boundaries = [];
280
- for (let column = 0; column < row.cells.length; column += 1) {
281
- right += pointsToTwips(table.columnWidthsPt[column] ?? 0);
282
- boundaries.push(right);
425
+ let column = 0;
426
+ const definitions = [];
427
+ const marks = [];
428
+ for (const [cellIndex, cell] of row.cells.entries()) {
429
+ const colSpan = cell.colSpan ?? 1;
430
+ for (let offset = 0; offset < colSpan; offset += 1) {
431
+ right += pointsToTwips(table.columnWidthsPt[column] ?? 0);
432
+ column += 1;
433
+ definitions.push(this.cellDefinition(cell, covered[rowIndex]?.[cellIndex] === true, colSpan > 1 ? offset === 0 ? "mergeFirst" : "mergeContinuation" : "single", right));
434
+ marks.push({
435
+ cell,
436
+ empty: offset > 0
437
+ });
438
+ }
283
439
  }
284
- const rowDefinition = `\\trowd\\trgaph108\\trleft0${boundaries.map((boundary) => `\\cellx${String(boundary)}`).join("")}`;
440
+ const rowDefinition = `\\trowd\\trgaph108\\trleft0${definitions.join("")}`;
285
441
  this.line(rowDefinition);
286
- for (const cell of row.cells) {
287
- if (cell.borders !== void 0 || cell.background !== void 0) this.sink({
288
- code: RtfDiagnosticCodes.CELL_BORDER_DROPPED,
289
- severity: "info",
290
- message: "a table cell's borders or background are dropped; this writer emits cell boundaries only"
291
- });
292
- this.writeCellBlocks(cell.blocks);
442
+ for (const mark of marks) {
443
+ this.writeCellBlocks(mark.empty ? [] : mark.cell.blocks);
293
444
  this.raw("\\cell");
294
445
  }
295
446
  this.line(`${rowDefinition}\\row`);
296
447
  }
297
448
  this.line("\\pard");
298
449
  }
450
+ cellDefinition(cell, isVerticalContinuation, horizontal, rightTwips) {
451
+ let out = "";
452
+ if (isVerticalContinuation) out += "\\clvmrg";
453
+ else if ((cell.rowSpan ?? 1) > 1) out += "\\clvmgf";
454
+ if (horizontal === "mergeFirst") out += "\\clmgf";
455
+ else if (horizontal === "mergeContinuation") return `${out}\\clmrg\\cellx${String(rightTwips)}`;
456
+ const borders = cell.borders;
457
+ if (borders !== void 0) for (const side of CELL_BORDER_ORDER) {
458
+ const border = borders[side];
459
+ if (border !== void 0) out += borderControlWords(side, border, colorIndexOf(border.color, this.tables.colors));
460
+ }
461
+ if (cell.background !== void 0) {
462
+ const index = colorIndexOf(cell.background, this.tables.colors);
463
+ if (index !== void 0) out += `\\clcbpat${String(index)}`;
464
+ }
465
+ return `${out}\\cellx${String(rightTwips)}`;
466
+ }
299
467
  writeCellBlocks(blocks) {
300
468
  const paragraphs = blocks.filter((block) => block.kind === "paragraph");
301
469
  if (paragraphs.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rtf-codec",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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": {
@@ -78,7 +78,7 @@
78
78
  "license": "MIT",
79
79
  "packageManager": "pnpm@11.6.0",
80
80
  "dependencies": {
81
- "document-schema.js": "^5.4.0",
81
+ "document-schema.js": "^5.5.1",
82
82
  "zod": "^4.4.3"
83
83
  },
84
84
  "devDependencies": {