@beyondwork/docx-react-component 1.0.10 → 1.0.12

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,8 +1,10 @@
1
1
  import {
2
2
  CDS_SCHEMA_VERSION,
3
3
  COMPATIBILITY_REPORT_VERSION,
4
+ LEGACY_PERSISTED_EDITOR_SNAPSHOT_VERSION,
4
5
  PERSISTED_EDITOR_SNAPSHOT_VERSION,
5
6
  type CompatibilityReportVersion,
7
+ type Base64,
6
8
  type ISO8601DateTime,
7
9
  type ModelValidationIssue,
8
10
  type PersistedEditorSnapshotVersion,
@@ -87,6 +89,15 @@ export interface CompatibilityReport {
87
89
  errors: EditorError[];
88
90
  }
89
91
 
92
+ export interface PersistedSourcePackage {
93
+ format: "docx";
94
+ storage: "embedded-base64";
95
+ mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
96
+ sourceLabel?: string;
97
+ sha256Hex: string;
98
+ bytesBase64: Base64;
99
+ }
100
+
90
101
  export interface PersistedEditorSnapshot {
91
102
  snapshotVersion: PersistedEditorSnapshotVersion;
92
103
  schemaVersion: typeof CDS_SCHEMA_VERSION;
@@ -99,9 +110,10 @@ export interface PersistedEditorSnapshot {
99
110
  canonicalDocument: CanonicalDocument;
100
111
  compatibility: CompatibilityReport;
101
112
  warningLog: EditorWarning[];
113
+ sourcePackage?: PersistedSourcePackage;
102
114
  }
103
115
 
104
- const SNAPSHOT_TOP_LEVEL_KEYS = [
116
+ const SNAPSHOT_REQUIRED_TOP_LEVEL_KEYS = [
105
117
  "snapshotVersion",
106
118
  "schemaVersion",
107
119
  "documentId",
@@ -115,6 +127,56 @@ const SNAPSHOT_TOP_LEVEL_KEYS = [
115
127
  "warningLog",
116
128
  ] as const;
117
129
 
130
+ const SNAPSHOT_OPTIONAL_TOP_LEVEL_KEYS = ["sourcePackage"] as const;
131
+
132
+ const PERSISTED_EDITOR_SNAPSHOT_VERSIONS = new Set<PersistedEditorSnapshotVersion>([
133
+ LEGACY_PERSISTED_EDITOR_SNAPSHOT_VERSION,
134
+ PERSISTED_EDITOR_SNAPSHOT_VERSION,
135
+ ]);
136
+
137
+ const DOCX_SOURCE_PACKAGE_MIME_TYPE =
138
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
139
+ const COMPATIBILITY_FEATURE_CLASSES = new Set<CompatibilityFeatureClass>([
140
+ "supported-roundtrip",
141
+ "preserve-only",
142
+ "unsupported-fatal",
143
+ ]);
144
+ const EDITOR_WARNING_CODES = new Set<EditorWarningCode>([
145
+ "unsupported_ooxml_preserved",
146
+ "unsupported_ooxml_locked",
147
+ "import_normalized",
148
+ "export_roundtrip_risk",
149
+ "comment_anchor_detached",
150
+ "revision_anchor_detached",
151
+ "large_document_degraded",
152
+ "font_substitution",
153
+ "image_missing",
154
+ ]);
155
+ const EDITOR_WARNING_SEVERITIES = new Set<EditorWarning["severity"]>(["info", "warning"]);
156
+ const EDITOR_WARNING_SOURCES = new Set<EditorWarning["source"]>([
157
+ "import",
158
+ "runtime",
159
+ "review",
160
+ "preservation",
161
+ "validation",
162
+ "export",
163
+ ]);
164
+ const EDITOR_ERROR_CODES = new Set<EditorErrorCode>([
165
+ "import_failed",
166
+ "export_failed",
167
+ "package_corrupt",
168
+ "validation_failed",
169
+ "datastore_failed",
170
+ "internal_invariant",
171
+ ]);
172
+ const EDITOR_ERROR_SOURCES = new Set<EditorError["source"]>([
173
+ "import",
174
+ "runtime",
175
+ "validation",
176
+ "datastore",
177
+ "export",
178
+ ]);
179
+
118
180
  export function serializePersistedEditorSnapshot(
119
181
  snapshot: PersistedEditorSnapshot,
120
182
  ): string {
@@ -146,14 +208,15 @@ export function validatePersistedEditorSnapshot(
146
208
  return issues;
147
209
  }
148
210
 
149
- validateExactObjectKeys(record, SNAPSHOT_TOP_LEVEL_KEYS, "$", issues);
150
-
151
- expectExactString(
152
- record.snapshotVersion,
153
- PERSISTED_EDITOR_SNAPSHOT_VERSION,
154
- "$.snapshotVersion",
211
+ validateExactObjectKeys(
212
+ record,
213
+ SNAPSHOT_REQUIRED_TOP_LEVEL_KEYS,
214
+ SNAPSHOT_OPTIONAL_TOP_LEVEL_KEYS,
215
+ "$",
155
216
  issues,
156
217
  );
218
+
219
+ validateSnapshotVersion(record.snapshotVersion, "$.snapshotVersion", issues);
157
220
  expectExactString(
158
221
  record.schemaVersion,
159
222
  CDS_SCHEMA_VERSION,
@@ -193,6 +256,10 @@ export function validatePersistedEditorSnapshot(
193
256
  });
194
257
  }
195
258
 
259
+ if (record.sourcePackage !== undefined) {
260
+ validatePersistedSourcePackage(record.sourcePackage, "$.sourcePackage", issues);
261
+ }
262
+
196
263
  return issues;
197
264
  }
198
265
 
@@ -280,7 +347,15 @@ function validateCompatibilityFeatureEntry(
280
347
 
281
348
  expectString(record.featureEntryId, `${path}.featureEntryId`, issues);
282
349
  expectString(record.featureKey, `${path}.featureKey`, issues);
283
- expectString(record.featureClass, `${path}.featureClass`, issues);
350
+ if (
351
+ typeof record.featureClass !== "string" ||
352
+ !COMPATIBILITY_FEATURE_CLASSES.has(record.featureClass as CompatibilityFeatureClass)
353
+ ) {
354
+ issues.push({
355
+ path: `${path}.featureClass`,
356
+ message: "featureClass must be supported-roundtrip, preserve-only, or unsupported-fatal.",
357
+ });
358
+ }
284
359
  expectString(record.message, `${path}.message`, issues);
285
360
  }
286
361
 
@@ -295,10 +370,31 @@ function validateEditorWarning(
295
370
  }
296
371
 
297
372
  expectString(record.warningId, `${path}.warningId`, issues);
298
- expectString(record.code, `${path}.code`, issues);
299
- expectString(record.severity, `${path}.severity`, issues);
373
+ if (typeof record.code !== "string" || !EDITOR_WARNING_CODES.has(record.code as EditorWarningCode)) {
374
+ issues.push({
375
+ path: `${path}.code`,
376
+ message: `code must be one of ${JSON.stringify([...EDITOR_WARNING_CODES])}.`,
377
+ });
378
+ }
379
+ if (
380
+ typeof record.severity !== "string" ||
381
+ !EDITOR_WARNING_SEVERITIES.has(record.severity as EditorWarning["severity"])
382
+ ) {
383
+ issues.push({
384
+ path: `${path}.severity`,
385
+ message: "severity must be info or warning.",
386
+ });
387
+ }
300
388
  expectString(record.message, `${path}.message`, issues);
301
- expectString(record.source, `${path}.source`, issues);
389
+ if (
390
+ typeof record.source !== "string" ||
391
+ !EDITOR_WARNING_SOURCES.has(record.source as EditorWarning["source"])
392
+ ) {
393
+ issues.push({
394
+ path: `${path}.source`,
395
+ message: `source must be one of ${JSON.stringify([...EDITOR_WARNING_SOURCES])}.`,
396
+ });
397
+ }
302
398
  }
303
399
 
304
400
  function validateEditorError(
@@ -312,9 +408,22 @@ function validateEditorError(
312
408
  }
313
409
 
314
410
  expectString(record.errorId, `${path}.errorId`, issues);
315
- expectString(record.code, `${path}.code`, issues);
411
+ if (typeof record.code !== "string" || !EDITOR_ERROR_CODES.has(record.code as EditorErrorCode)) {
412
+ issues.push({
413
+ path: `${path}.code`,
414
+ message: `code must be one of ${JSON.stringify([...EDITOR_ERROR_CODES])}.`,
415
+ });
416
+ }
316
417
  expectString(record.message, `${path}.message`, issues);
317
- expectString(record.source, `${path}.source`, issues);
418
+ if (
419
+ typeof record.source !== "string" ||
420
+ !EDITOR_ERROR_SOURCES.has(record.source as EditorError["source"])
421
+ ) {
422
+ issues.push({
423
+ path: `${path}.source`,
424
+ message: `source must be one of ${JSON.stringify([...EDITOR_ERROR_SOURCES])}.`,
425
+ });
426
+ }
318
427
 
319
428
  if (typeof record.isFatal !== "boolean") {
320
429
  issues.push({
@@ -326,24 +435,25 @@ function validateEditorError(
326
435
 
327
436
  function validateExactObjectKeys(
328
437
  record: Record<string, unknown>,
329
- expectedKeys: readonly string[],
438
+ requiredKeys: readonly string[],
439
+ optionalKeys: readonly string[],
330
440
  path: string,
331
441
  issues: ModelValidationIssue[],
332
442
  ): void {
333
443
  const actualKeys = new Set(Object.keys(record));
334
- const expected = new Set(expectedKeys);
444
+ const allowedKeys = new Set([...requiredKeys, ...optionalKeys]);
335
445
 
336
- for (const expectedKey of expectedKeys) {
337
- if (!actualKeys.has(expectedKey)) {
446
+ for (const requiredKey of requiredKeys) {
447
+ if (!actualKeys.has(requiredKey)) {
338
448
  issues.push({
339
449
  path,
340
- message: `Missing required key ${JSON.stringify(expectedKey)}.`,
450
+ message: `Missing required key ${JSON.stringify(requiredKey)}.`,
341
451
  });
342
452
  }
343
453
  }
344
454
 
345
455
  for (const actualKey of actualKeys) {
346
- if (!expected.has(actualKey)) {
456
+ if (!allowedKeys.has(actualKey)) {
347
457
  issues.push({
348
458
  path: `${path}.${actualKey}`,
349
459
  message:
@@ -360,6 +470,65 @@ function prefixIssue(basePath: string) {
360
470
  });
361
471
  }
362
472
 
473
+ function validateSnapshotVersion(
474
+ value: unknown,
475
+ path: string,
476
+ issues: ModelValidationIssue[],
477
+ ): PersistedEditorSnapshotVersion | null {
478
+ if (typeof value !== "string" || !PERSISTED_EDITOR_SNAPSHOT_VERSIONS.has(value as never)) {
479
+ issues.push({
480
+ path,
481
+ message: `Expected one of ${JSON.stringify([...PERSISTED_EDITOR_SNAPSHOT_VERSIONS])}.`,
482
+ });
483
+ return null;
484
+ }
485
+
486
+ return value as PersistedEditorSnapshotVersion;
487
+ }
488
+
489
+ function validatePersistedSourcePackage(
490
+ sourcePackage: unknown,
491
+ path: string,
492
+ issues: ModelValidationIssue[],
493
+ ): void {
494
+ const record = asPlainObject(sourcePackage, path, issues);
495
+ if (!record) {
496
+ return;
497
+ }
498
+
499
+ validateExactObjectKeys(
500
+ record,
501
+ ["format", "storage", "mimeType", "sha256Hex", "bytesBase64"],
502
+ ["sourceLabel"],
503
+ path,
504
+ issues,
505
+ );
506
+ expectExactString(record.format, "docx", `${path}.format`, issues);
507
+ expectExactString(record.storage, "embedded-base64", `${path}.storage`, issues);
508
+ expectExactString(
509
+ record.mimeType,
510
+ DOCX_SOURCE_PACKAGE_MIME_TYPE,
511
+ `${path}.mimeType`,
512
+ issues,
513
+ );
514
+ expectString(record.sha256Hex, `${path}.sha256Hex`, issues);
515
+ expectString(record.bytesBase64, `${path}.bytesBase64`, issues);
516
+
517
+ if (
518
+ typeof record.sha256Hex === "string" &&
519
+ !/^[0-9a-f]{64}$/u.test(record.sha256Hex)
520
+ ) {
521
+ issues.push({
522
+ path: `${path}.sha256Hex`,
523
+ message: "Expected a lowercase SHA-256 hex digest.",
524
+ });
525
+ }
526
+
527
+ if (record.sourceLabel !== undefined) {
528
+ expectString(record.sourceLabel, `${path}.sourceLabel`, issues);
529
+ }
530
+ }
531
+
363
532
  export function createPersistedEditorSnapshot(params: {
364
533
  documentId: string;
365
534
  savedAt: ISO8601DateTime;
@@ -367,6 +536,7 @@ export function createPersistedEditorSnapshot(params: {
367
536
  canonicalDocument: CanonicalDocument;
368
537
  compatibility: CompatibilityReport;
369
538
  warningLog?: EditorWarning[];
539
+ sourcePackage?: PersistedSourcePackage;
370
540
  }): PersistedEditorSnapshot {
371
541
  assertCanonicalDocument(params.canonicalDocument);
372
542
  assertCompatibilityReport(params.compatibility);
@@ -383,6 +553,7 @@ export function createPersistedEditorSnapshot(params: {
383
553
  canonicalDocument: params.canonicalDocument,
384
554
  compatibility: params.compatibility,
385
555
  warningLog: params.warningLog ?? params.compatibility.warnings,
556
+ sourcePackage: params.sourcePackage,
386
557
  };
387
558
  }
388
559
 
@@ -5,14 +5,7 @@ import type { OpcPackage } from "../io/opc/package-reader.ts";
5
5
  const CORE_NON_PRESERVED_PART_PATHS = new Set([
6
6
  "/docProps/app.xml",
7
7
  "/docProps/core.xml",
8
- "/docProps/custom.xml",
9
- "/word/fontTable.xml",
10
8
  "/word/numbering.xml",
11
- "/word/settings.xml",
12
- "/word/styles.xml",
13
- "/word/stylesWithEffects.xml",
14
- "/word/theme/theme1.xml",
15
- "/word/webSettings.xml",
16
9
  ]);
17
10
 
18
11
  export function collectPreservedPackageParts(
@@ -48,6 +48,7 @@ import {
48
48
  type RevisionStore,
49
49
  } from "../review/store/revision-store.ts";
50
50
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
51
+ import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
51
52
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
52
53
 
53
54
  export type Unsubscribe = () => void;
@@ -724,12 +725,17 @@ function countOpaqueFragments(opaqueFragments: Record<string, unknown>): number
724
725
  }
725
726
 
726
727
  function createDerivedCompatibility(state: EditorState): InternalCompatibilityReport {
727
- return buildCompatibilityReport({
728
+ const derived = buildCompatibilityReport({
728
729
  document: state.document,
729
730
  warnings: state.warnings,
730
731
  fatalError: state.fatalError,
731
732
  generatedAt: state.document.updatedAt,
732
733
  });
734
+
735
+ return mergeCompatibilityReports([state.compatibility as never, derived as never], {
736
+ generatedAt: state.document.updatedAt,
737
+ blockExport: state.compatibility.blockExport || derived.blockExport,
738
+ }) as InternalCompatibilityReport;
733
739
  }
734
740
 
735
741
  function toPublicCommentSidebarSnapshot(
@@ -71,7 +71,7 @@ function createDiagnosticsSnapshot(input: {
71
71
  const diagnosticId = `diagnostic:${sanitizeDiagnosticsToken(input.documentId)}`;
72
72
 
73
73
  return {
74
- snapshotVersion: "persisted-editor-snapshot/1",
74
+ snapshotVersion: "persisted-editor-snapshot/2",
75
75
  schemaVersion: "cds/1.0.0",
76
76
  documentId: input.documentId,
77
77
  docId,
@@ -4,6 +4,7 @@ import type {
4
4
  SurfaceInlineSegment,
5
5
  SurfaceTableCellSnapshot,
6
6
  SurfaceTableRowSnapshot,
7
+ SurfaceTextMark,
7
8
  } from "../api/public-types";
8
9
  import type {
9
10
  CanonicalDocumentEnvelope,
@@ -208,10 +209,11 @@ function createTableBlock(
208
209
  const lockedFragmentIds: string[] = [];
209
210
  let innerCursor = cursor;
210
211
  const rows: SurfaceTableRowSnapshot[] = [];
212
+ const rowSpans = computeTableRowSpans(table);
211
213
 
212
- for (const row of table.rows) {
214
+ for (const [rowIndex, row] of table.rows.entries()) {
213
215
  const cells: SurfaceTableCellSnapshot[] = [];
214
- for (const cell of row.cells) {
216
+ for (const [cellIndex, cell] of row.cells.entries()) {
215
217
  const cellContent: SurfaceBlockSnapshot[] = [];
216
218
  for (const child of cell.children) {
217
219
  const result = createSurfaceBlock(child, document, innerCursor, counters);
@@ -223,7 +225,7 @@ function createTableBlock(
223
225
  gridSpan: cell.gridSpan ?? 1,
224
226
  verticalMerge: cell.verticalMerge ?? null,
225
227
  colspan: cell.gridSpan ?? 1,
226
- rowspan: cell.verticalMerge === "restart" ? 1 : 1,
228
+ rowspan: rowSpans.get(`${rowIndex}:${cellIndex}`) ?? 1,
227
229
  content: cellContent,
228
230
  });
229
231
  }
@@ -245,6 +247,55 @@ function createTableBlock(
245
247
  };
246
248
  }
247
249
 
250
+ function computeTableRowSpans(table: TableNode): Map<string, number> {
251
+ const positionedRows = table.rows.map((row) => {
252
+ let startColumn = 0;
253
+
254
+ return row.cells.map((cell, cellIndex) => {
255
+ const width = cell.gridSpan ?? 1;
256
+ const positionedCell = {
257
+ cell,
258
+ cellIndex,
259
+ startColumn,
260
+ endColumn: startColumn + width - 1,
261
+ };
262
+ startColumn += width;
263
+ return positionedCell;
264
+ });
265
+ });
266
+
267
+ const rowSpans = new Map<string, number>();
268
+
269
+ for (const [rowIndex, row] of positionedRows.entries()) {
270
+ for (const positionedCell of row) {
271
+ if (positionedCell.cell.verticalMerge !== "restart") {
272
+ rowSpans.set(`${rowIndex}:${positionedCell.cellIndex}`, 1);
273
+ continue;
274
+ }
275
+
276
+ let rowspan = 1;
277
+ for (let nextRowIndex = rowIndex + 1; nextRowIndex < positionedRows.length; nextRowIndex += 1) {
278
+ const continuedCell = positionedRows[nextRowIndex]?.find(
279
+ (candidate) =>
280
+ candidate.cell.verticalMerge === "continue" &&
281
+ candidate.startColumn === positionedCell.startColumn &&
282
+ candidate.endColumn === positionedCell.endColumn,
283
+ );
284
+
285
+ if (!continuedCell) {
286
+ break;
287
+ }
288
+
289
+ rowspan += 1;
290
+ }
291
+
292
+ rowSpans.set(`${rowIndex}:${positionedCell.cellIndex}`, rowspan);
293
+ }
294
+ }
295
+
296
+ return rowSpans;
297
+ }
298
+
248
299
  function createSdtBlock(
249
300
  sdtIndex: number,
250
301
  block: SdtNode,
@@ -304,6 +355,17 @@ function createParagraphBlock(
304
355
  to: start,
305
356
  ...(paragraph.styleId ? { styleId: paragraph.styleId } : {}),
306
357
  ...(paragraph.numbering ? { numbering: paragraph.numbering } : {}),
358
+ ...(paragraph.alignment ? { alignment: paragraph.alignment } : {}),
359
+ ...(paragraph.spacing ? { spacing: paragraph.spacing } : {}),
360
+ ...(paragraph.indentation ? { indentation: paragraph.indentation } : {}),
361
+ ...(paragraph.borders ? { borders: paragraph.borders } : {}),
362
+ ...(paragraph.shading ? { shading: paragraph.shading } : {}),
363
+ ...(paragraph.tabStops && paragraph.tabStops.length > 0 ? { tabStops: paragraph.tabStops } : {}),
364
+ ...(paragraph.keepNext ? { keepNext: true } : {}),
365
+ ...(paragraph.keepLines ? { keepLines: true } : {}),
366
+ ...(paragraph.pageBreakBefore ? { pageBreakBefore: true } : {}),
367
+ ...(paragraph.outlineLevel !== undefined ? { outlineLevel: paragraph.outlineLevel } : {}),
368
+ ...(paragraph.bidi ? { bidi: true } : {}),
307
369
  segments: [],
308
370
  };
309
371
  const lockedFragmentIds: string[] = [];
@@ -339,7 +401,13 @@ function appendInlineSegments(
339
401
  from: start,
340
402
  to: start + Array.from(node.text).length,
341
403
  text: node.text,
342
- ...(node.marks ? { marks: cloneMarks(node.marks) } : {}),
404
+ ...(node.marks ? (() => {
405
+ const result = cloneMarks(node.marks);
406
+ return {
407
+ ...(result.marks.length > 0 ? { marks: result.marks } : {}),
408
+ ...(result.markAttrs ? { markAttrs: result.markAttrs } : {}),
409
+ };
410
+ })() : {}),
343
411
  ...(hyperlinkHref ? { hyperlinkHref } : {}),
344
412
  });
345
413
  return { nextCursor: start + Array.from(node.text).length, lockedFragmentIds: [] };
@@ -415,12 +483,66 @@ function appendInlineSegments(
415
483
  return appendComplexPreviewSegment(paragraph, node, start, "WordArt", createWordArtDetail(node));
416
484
  case "vml_shape":
417
485
  return appendComplexPreviewSegment(paragraph, node, start, "VML shape", createVmlDetail(node));
418
- case "column_break":
419
486
  case "symbol":
420
- case "field":
487
+ paragraph.segments.push({
488
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
489
+ kind: "text",
490
+ from: start,
491
+ to: start + 1,
492
+ text: node.char ? String.fromCodePoint(parseInt(node.char, 16)) : "\uFFFD",
493
+ });
494
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
495
+ case "column_break":
496
+ paragraph.segments.push({
497
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
498
+ kind: "opaque_inline",
499
+ from: start,
500
+ to: start + 1,
501
+ fragmentId: "",
502
+ warningId: "",
503
+ label: "Column break",
504
+ detail: "Column break marker preserved for export.",
505
+ state: "locked-preserve-only",
506
+ });
507
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
508
+ case "footnote_ref":
509
+ paragraph.segments.push({
510
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
511
+ kind: "text",
512
+ from: start,
513
+ to: start + 1,
514
+ text: node.noteId ?? "*",
515
+ marks: ["superscript" as SurfaceTextMark],
516
+ });
517
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
518
+ case "field": {
519
+ if (node.children && node.children.length > 0) {
520
+ let cursor = start;
521
+ const lockedIds: string[] = [];
522
+ for (const child of node.children) {
523
+ const result = appendInlineSegments(paragraph, child, document, cursor);
524
+ cursor = result.nextCursor;
525
+ lockedIds.push(...result.lockedFragmentIds);
526
+ }
527
+ return { nextCursor: cursor, lockedFragmentIds: lockedIds };
528
+ }
529
+ paragraph.segments.push({
530
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
531
+ kind: "opaque_inline",
532
+ from: start,
533
+ to: start + 1,
534
+ fragmentId: "",
535
+ warningId: "",
536
+ label: "Field",
537
+ detail: "Field code preserved for export.",
538
+ state: "locked-preserve-only",
539
+ });
540
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
541
+ }
421
542
  case "bookmark_start":
422
543
  case "bookmark_end":
423
- case "footnote_ref":
544
+ // Zero-width markers — no visual, no cursor advancement
545
+ return { nextCursor: start, lockedFragmentIds: [] };
424
546
  default:
425
547
  return { nextCursor: start + 1, lockedFragmentIds: [] };
426
548
  }
@@ -527,19 +649,79 @@ function createPlainText(
527
649
  return text.join("");
528
650
  }
529
651
 
530
- function cloneMarks(marks: TextMark[]): Array<"bold" | "italic" | "underline" | "strikethrough"> {
531
- const supported: Array<"bold" | "italic" | "underline" | "strikethrough"> = [];
652
+ function cloneMarks(marks: TextMark[]): {
653
+ marks: SurfaceTextMark[];
654
+ markAttrs?: {
655
+ backgroundColor?: string;
656
+ charSpacing?: number;
657
+ kerning?: number;
658
+ textFill?: string;
659
+ fontFamily?: string;
660
+ fontSize?: number;
661
+ textColor?: string;
662
+ };
663
+ } {
664
+ const supported: SurfaceTextMark[] = [];
665
+ const attrs: {
666
+ backgroundColor?: string;
667
+ charSpacing?: number;
668
+ kerning?: number;
669
+ textFill?: string;
670
+ fontFamily?: string;
671
+ fontSize?: number;
672
+ textColor?: string;
673
+ } = {};
532
674
  for (const mark of marks) {
533
- if (
534
- mark.type === "bold" ||
535
- mark.type === "italic" ||
536
- mark.type === "underline" ||
537
- mark.type === "strikethrough"
538
- ) {
539
- supported.push(mark.type);
675
+ switch (mark.type) {
676
+ case "bold":
677
+ case "italic":
678
+ case "underline":
679
+ case "strikethrough":
680
+ case "doubleStrikethrough":
681
+ case "vanish":
682
+ case "emboss":
683
+ case "imprint":
684
+ case "shadow":
685
+ supported.push(mark.type);
686
+ break;
687
+ case "position":
688
+ if (mark.val > 0) supported.push("superscript");
689
+ else if (mark.val < 0) supported.push("subscript");
690
+ break;
691
+ case "backgroundColor":
692
+ attrs.backgroundColor = mark.color;
693
+ break;
694
+ case "charSpacing":
695
+ attrs.charSpacing = mark.val;
696
+ break;
697
+ case "kerning":
698
+ attrs.kerning = mark.val;
699
+ break;
700
+ case "textFill":
701
+ attrs.textFill = mark.xml;
702
+ break;
703
+ case "fontFamily":
704
+ attrs.fontFamily = mark.val;
705
+ break;
706
+ case "fontSize":
707
+ attrs.fontSize = mark.val;
708
+ break;
709
+ case "textColor":
710
+ attrs.textColor = mark.color;
711
+ break;
712
+ case "smallCaps":
713
+ supported.push("smallCaps");
714
+ break;
715
+ case "allCaps":
716
+ supported.push("allCaps");
717
+ break;
718
+ case "lang":
719
+ // no visual effect — skip
720
+ break;
540
721
  }
541
722
  }
542
- return supported;
723
+ const hasAttrs = Object.keys(attrs).length > 0;
724
+ return hasAttrs ? { marks: supported, markAttrs: attrs } : { marks: supported };
543
725
  }
544
726
 
545
727
  function normalizeDocumentRoot(content: unknown): DocumentRootNode {