@lotics/docx 0.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.
Files changed (107) hide show
  1. package/package.json +40 -0
  2. package/src/fixtures/.gitkeep +0 -0
  3. package/src/fixtures/lotics_generated_contract.docx +0 -0
  4. package/src/fonts/bundled.ts +123 -0
  5. package/src/fonts/registry.test.ts +233 -0
  6. package/src/fonts/registry.ts +219 -0
  7. package/src/fonts/types.ts +83 -0
  8. package/src/index.ts +16 -0
  9. package/src/layout/engine.test.ts +430 -0
  10. package/src/layout/engine.ts +566 -0
  11. package/src/layout/page_geometry.ts +43 -0
  12. package/src/layout/types.ts +159 -0
  13. package/src/load.test.ts +144 -0
  14. package/src/load.ts +142 -0
  15. package/src/model/default_numbering.ts +101 -0
  16. package/src/model/default_styles.ts +201 -0
  17. package/src/model/numbering_table.ts +52 -0
  18. package/src/model/properties.ts +328 -0
  19. package/src/model/sections.ts +94 -0
  20. package/src/model/style_resolution.test.ts +219 -0
  21. package/src/model/style_resolution.ts +113 -0
  22. package/src/model/style_table.ts +22 -0
  23. package/src/model/theme.ts +156 -0
  24. package/src/model/types.ts +55 -0
  25. package/src/parse/drawing.ts +157 -0
  26. package/src/parse/font_table.ts +132 -0
  27. package/src/parse/footnotes.ts +60 -0
  28. package/src/parse/header_footer.test.ts +264 -0
  29. package/src/parse/header_footer.ts +66 -0
  30. package/src/parse/numbering.ts +187 -0
  31. package/src/parse/parser.ts +184 -0
  32. package/src/parse/relationships.ts +83 -0
  33. package/src/parse/sections.test.ts +192 -0
  34. package/src/parse/sections.ts +182 -0
  35. package/src/parse/styles.ts +149 -0
  36. package/src/parse/theme.test.ts +86 -0
  37. package/src/parse/theme.ts +112 -0
  38. package/src/pm/bubble_menu.ts +117 -0
  39. package/src/pm/commands.test.ts +185 -0
  40. package/src/pm/commands.ts +697 -0
  41. package/src/pm/commands_insert.test.ts +183 -0
  42. package/src/pm/docx_to_pm.test.ts +330 -0
  43. package/src/pm/docx_to_pm.ts +643 -0
  44. package/src/pm/drag_handle.ts +166 -0
  45. package/src/pm/format_painter.test.ts +91 -0
  46. package/src/pm/format_painter.ts +109 -0
  47. package/src/pm/header_footer_doc.ts +24 -0
  48. package/src/pm/hyperlinks.test.ts +234 -0
  49. package/src/pm/image_registry.test.ts +81 -0
  50. package/src/pm/image_registry.ts +100 -0
  51. package/src/pm/images.test.ts +257 -0
  52. package/src/pm/link_popover.ts +159 -0
  53. package/src/pm/mark_commands.ts +60 -0
  54. package/src/pm/marks.ts +169 -0
  55. package/src/pm/nodes.ts +258 -0
  56. package/src/pm/numbering.test.ts +210 -0
  57. package/src/pm/numbering_plugin.test.ts +71 -0
  58. package/src/pm/numbering_plugin.ts +96 -0
  59. package/src/pm/outline.ts +41 -0
  60. package/src/pm/page_break.test.ts +80 -0
  61. package/src/pm/page_layout.test.ts +87 -0
  62. package/src/pm/pagination_plugin.test.ts +155 -0
  63. package/src/pm/pagination_plugin.ts +590 -0
  64. package/src/pm/phase5.test.ts +271 -0
  65. package/src/pm/phase6.test.ts +215 -0
  66. package/src/pm/placeholder_plugin.ts +24 -0
  67. package/src/pm/plugins.ts +91 -0
  68. package/src/pm/pm_to_docx.ts +0 -0
  69. package/src/pm/roundtrip.test.ts +332 -0
  70. package/src/pm/schema.test.ts +188 -0
  71. package/src/pm/schema.ts +79 -0
  72. package/src/pm/search.ts +46 -0
  73. package/src/pm/table_attrs.ts +48 -0
  74. package/src/pm/table_borders.test.ts +117 -0
  75. package/src/pm/table_borders.ts +130 -0
  76. package/src/pm/table_convert.test.ts +221 -0
  77. package/src/pm/table_convert.ts +541 -0
  78. package/src/pm/table_decorations.ts +132 -0
  79. package/src/pm/table_handles.ts +163 -0
  80. package/src/pm/template_marker.ts +47 -0
  81. package/src/pm/template_plugin.ts +65 -0
  82. package/src/pm/templates.test.ts +162 -0
  83. package/src/render/clipboard.test.ts +115 -0
  84. package/src/render/clipboard.ts +200 -0
  85. package/src/render/editable_view.test.ts +173 -0
  86. package/src/render/footnotes_view.ts +94 -0
  87. package/src/render/header_footer_view.ts +95 -0
  88. package/src/render/link_mark_view.ts +26 -0
  89. package/src/render/media_resolver.ts +61 -0
  90. package/src/render/node_views.ts +296 -0
  91. package/src/render/numbering_counter.ts +149 -0
  92. package/src/render/page_chrome.test.ts +262 -0
  93. package/src/render/page_chrome.ts +343 -0
  94. package/src/render/page_styles.ts +234 -0
  95. package/src/render/paragraph_view.test.ts +162 -0
  96. package/src/render/paragraph_view.ts +141 -0
  97. package/src/render/ruler.ts +110 -0
  98. package/src/render/style_registry.ts +33 -0
  99. package/src/render/table_dom.test.ts +171 -0
  100. package/src/render/table_dom.ts +288 -0
  101. package/src/render/units.ts +18 -0
  102. package/src/render/view.test.ts +165 -0
  103. package/src/render/view.ts +607 -0
  104. package/src/roundtrip.test.ts +179 -0
  105. package/src/serialize/default_parts.ts +128 -0
  106. package/src/serialize/header_footer_pm.ts +82 -0
  107. package/src/serialize/serializer.ts +114 -0
@@ -0,0 +1,343 @@
1
+ import type {
2
+ HeaderFooterFragment,
3
+ HeaderFooterRegistry,
4
+ } from "../parse/header_footer";
5
+ import type { Section } from "../model/sections";
6
+ import type { HeaderFooterKind, LayoutResult, SectionGeometry } from "../layout/types";
7
+ import { renderHeaderFooterFragment } from "./header_footer_view";
8
+
9
+ /**
10
+ * Visual gap between page rectangles. Word's "page setup" reserves
11
+ * the page margin around content; the gap is purely aesthetic to
12
+ * distinguish one page from the next.
13
+ */
14
+ export const DEFAULT_PAGE_GAP_PX = 24;
15
+
16
+ /**
17
+ * Optional factory the caller can use to override how a header or footer
18
+ * is mounted into a given page. View.ts uses this to attach an editable
19
+ * ProseMirror EditorView to page 1 in editable mode while the chrome
20
+ * manager continues to mount read-only DOM for pages 2..N.
21
+ *
22
+ * Return null to use the default (read-only DOM clone).
23
+ */
24
+ export type HeaderFooterFactory = (args: {
25
+ pageNumber: number;
26
+ kind: "hdr" | "ftr";
27
+ fragment: HeaderFooterFragment;
28
+ relationshipId: string | null;
29
+ /** True if this is the very first page of the document (pageNumber === 1). */
30
+ isFirstPage: boolean;
31
+ /** True if this page is the first page of its section. */
32
+ isFirstInSection: boolean;
33
+ /** Which header/footer variant the layout engine selected. */
34
+ headerKind: HeaderFooterKind;
35
+ }) => HTMLElement | null;
36
+
37
+ export type PageChromeOptions = {
38
+ host: HTMLElement;
39
+ sections: readonly Section[];
40
+ sectionGeometries: readonly SectionGeometry[];
41
+ headerFooterRegistry: HeaderFooterRegistry | null;
42
+ /** Visual gap between page rectangles in CSS pixels. Defaults to 24. */
43
+ pageGapPx?: number;
44
+ /**
45
+ * Override how header/footer DOM is built per page. If not provided or
46
+ * the override returns null, the manager falls back to read-only DOM.
47
+ */
48
+ renderHeaderFooter?: HeaderFooterFactory;
49
+ };
50
+
51
+ export type PageChromeManager = {
52
+ /**
53
+ * Reconcile the page rectangles to match the supplied layout. Diffs against
54
+ * the previous layout so unchanged pages are not re-rendered.
55
+ */
56
+ setLayout(layout: LayoutResult): void;
57
+ /**
58
+ * Replace the per-page footnote DOM. The map's key is the page number
59
+ * (1-based); the value is the DOM the manager should mount above that
60
+ * page's footer band. Pass an empty map to clear all footnotes.
61
+ */
62
+ setFootnotes(footnotesByPage: ReadonlyMap<number, HTMLElement>): void;
63
+ /**
64
+ * Total Y height occupied by the page chrome layer.
65
+ */
66
+ getTotalHeight(): number;
67
+ /**
68
+ * Per-page-boundary gap height in px. Used by the pagination plugin to
69
+ * emit widget decorations that visually separate page content.
70
+ *
71
+ * Returns an array of length `pageCount - 1`. Entry `i` is the gap that
72
+ * sits between page `i+1` and page `i+2`.
73
+ */
74
+ getGapHeights(): readonly number[];
75
+ destroy(): void;
76
+ };
77
+
78
+ /**
79
+ * Build the absolutely-positioned page rectangles that sit behind the
80
+ * editor content. The editor's contentDOM overlays this layer at the
81
+ * same Y origin; matching headerBand padding on the editor content keeps
82
+ * paragraphs aligned with each page's content area.
83
+ */
84
+ export function createPageChromeManager(
85
+ options: PageChromeOptions,
86
+ ): PageChromeManager {
87
+ const gapPx = options.pageGapPx ?? DEFAULT_PAGE_GAP_PX;
88
+ const layerEl = document.createElement("div");
89
+ layerEl.className = "docx-pages-layer";
90
+ layerEl.setAttribute("data-docx-pages-layer", "true");
91
+ options.host.appendChild(layerEl);
92
+
93
+ // Reconciliation key: pageNumber. We diff by pageNumber so unchanged
94
+ // pages retain their DOM (and any focus/selection state on editable
95
+ // header sub-views).
96
+ const mounted = new Map<number, MountedPage>();
97
+ let lastTotalHeight = 0;
98
+ let lastGapHeights: readonly number[] = [];
99
+
100
+ const setLayout = (layout: LayoutResult): void => {
101
+ const seen = new Set<number>();
102
+ let nextY = 0;
103
+ const gaps: number[] = [];
104
+
105
+ for (let i = 0; i < layout.pages.length; i++) {
106
+ const page = layout.pages[i];
107
+ const geometry = options.sectionGeometries[page.sectionIndex];
108
+ if (!geometry) continue;
109
+
110
+ let mountedPage = mounted.get(page.pageNumber);
111
+ if (
112
+ !mountedPage ||
113
+ mountedPage.sectionIndex !== page.sectionIndex ||
114
+ mountedPage.headerKind !== page.headerKind
115
+ ) {
116
+ // Mount or remount.
117
+ if (mountedPage) {
118
+ mountedPage.element.remove();
119
+ mounted.delete(page.pageNumber);
120
+ }
121
+ mountedPage = mountPage({
122
+ page,
123
+ geometry,
124
+ section: options.sections[page.sectionIndex],
125
+ registry: options.headerFooterRegistry,
126
+ factory: options.renderHeaderFooter,
127
+ isFirstPage: page.pageNumber === 1,
128
+ });
129
+ mounted.set(page.pageNumber, mountedPage);
130
+ layerEl.appendChild(mountedPage.element);
131
+ }
132
+
133
+ mountedPage.element.style.top = `${nextY}px`;
134
+ mountedPage.element.style.left = "0";
135
+ mountedPage.element.style.width = `${geometry.pageWidth}px`;
136
+ mountedPage.element.style.height = `${geometry.pageHeight}px`;
137
+
138
+ seen.add(page.pageNumber);
139
+
140
+ if (i < layout.pages.length - 1) {
141
+ // Gap between this page and the next.
142
+ const nextGeometry =
143
+ options.sectionGeometries[layout.pages[i + 1].sectionIndex] ??
144
+ geometry;
145
+ const sameSection =
146
+ layout.pages[i + 1].sectionIndex === page.sectionIndex;
147
+ // Always use the same gap height so the editor content's gap
148
+ // decoration is a fixed value. Section-boundary aesthetics are
149
+ // handled by the canvas, not the gap.
150
+ gaps.push(gapPx);
151
+ nextY += geometry.pageHeight + gapPx;
152
+ // Reference nextGeometry to avoid an unused-symbol lint when the
153
+ // next section has different page dimensions but we still use the
154
+ // shared gap.
155
+ void nextGeometry;
156
+ void sameSection;
157
+ } else {
158
+ nextY += geometry.pageHeight;
159
+ }
160
+ }
161
+
162
+ // Unmount pages no longer in the layout.
163
+ for (const [pageNum, mountedPage] of mounted) {
164
+ if (!seen.has(pageNum)) {
165
+ mountedPage.element.remove();
166
+ mounted.delete(pageNum);
167
+ }
168
+ }
169
+
170
+ layerEl.style.height = `${nextY}px`;
171
+ lastTotalHeight = nextY;
172
+ lastGapHeights = gaps;
173
+ };
174
+
175
+ const setFootnotes = (
176
+ footnotesByPage: ReadonlyMap<number, HTMLElement>,
177
+ ): void => {
178
+ for (const [pageNumber, mountedPage] of mounted) {
179
+ // Drop any existing footnote section, then re-mount the new one.
180
+ // NOTE: footnotes are read-only DOM today, so a plain `.remove()`
181
+ // is sufficient. If editable footnotes land in a later phase
182
+ // (Phase 5+), this needs to destroy any ProseMirror EditorView
183
+ // inside the removed section before unmounting, or every layout
184
+ // recompute will leak a PM view.
185
+ const existing = mountedPage.element.querySelector(
186
+ ".docx-page-footnotes",
187
+ );
188
+ existing?.remove();
189
+ const content = footnotesByPage.get(pageNumber);
190
+ if (!content) continue;
191
+ const wrapper = document.createElement("div");
192
+ wrapper.className = "docx-page-footnotes";
193
+ wrapper.appendChild(content);
194
+ // Insert just before the footer band so footnotes sit above the
195
+ // section footer at the bottom of the body slot.
196
+ const footerBand = mountedPage.element.querySelector(
197
+ ".docx-page-footer-band",
198
+ );
199
+ if (footerBand) {
200
+ mountedPage.element.insertBefore(wrapper, footerBand);
201
+ } else {
202
+ mountedPage.element.appendChild(wrapper);
203
+ }
204
+ }
205
+ };
206
+
207
+ return {
208
+ setLayout,
209
+ setFootnotes,
210
+ getTotalHeight: () => lastTotalHeight,
211
+ getGapHeights: () => lastGapHeights,
212
+ destroy: () => {
213
+ for (const mountedPage of mounted.values()) {
214
+ mountedPage.element.remove();
215
+ }
216
+ mounted.clear();
217
+ layerEl.remove();
218
+ },
219
+ };
220
+ }
221
+
222
+ // =========================================================================
223
+ // Internal: per-page mount
224
+ // =========================================================================
225
+
226
+ type MountedPage = {
227
+ element: HTMLElement;
228
+ sectionIndex: number;
229
+ headerKind: HeaderFooterKind;
230
+ };
231
+
232
+ function mountPage(args: {
233
+ page: LayoutResult["pages"][number];
234
+ geometry: SectionGeometry;
235
+ section: Section;
236
+ registry: HeaderFooterRegistry | null;
237
+ factory: HeaderFooterFactory | undefined;
238
+ isFirstPage: boolean;
239
+ }): MountedPage {
240
+ const pageEl = document.createElement("div");
241
+ pageEl.className = "docx-page";
242
+ pageEl.setAttribute("data-docx-page", String(args.page.pageNumber));
243
+ pageEl.setAttribute("data-section-index", String(args.page.sectionIndex));
244
+ pageEl.style.position = "absolute";
245
+
246
+ // Header band.
247
+ const headerEl = document.createElement("div");
248
+ headerEl.className = "docx-page-header-band";
249
+ headerEl.style.height = `${args.geometry.headerBand}px`;
250
+ pageEl.appendChild(headerEl);
251
+
252
+ // Body slot (transparent — editor content shows through).
253
+ const bodyEl = document.createElement("div");
254
+ bodyEl.className = "docx-page-body";
255
+ bodyEl.style.height = `${args.geometry.pageContentHeight}px`;
256
+ pageEl.appendChild(bodyEl);
257
+
258
+ // Footer band.
259
+ const footerEl = document.createElement("div");
260
+ footerEl.className = "docx-page-footer-band";
261
+ footerEl.style.height = `${args.geometry.footerBand}px`;
262
+ pageEl.appendChild(footerEl);
263
+
264
+ // Resolve and mount the header/footer for this page.
265
+ if (args.registry) {
266
+ const headerFragment = pickFragmentForKind(
267
+ args.section.properties.headerRefs,
268
+ args.page.headerKind,
269
+ args.registry,
270
+ );
271
+ if (headerFragment) {
272
+ const headerRef = pickRefForKind(
273
+ args.section.properties.headerRefs,
274
+ args.page.headerKind,
275
+ );
276
+ const mountedHeader =
277
+ args.factory?.({
278
+ pageNumber: args.page.pageNumber,
279
+ kind: "hdr",
280
+ fragment: headerFragment.fragment,
281
+ relationshipId: headerRef?.relationshipId ?? null,
282
+ isFirstPage: args.isFirstPage,
283
+ isFirstInSection: args.page.isFirstInSection,
284
+ headerKind: args.page.headerKind,
285
+ }) ?? renderHeaderFooterFragment(headerFragment.fragment);
286
+ mountedHeader.setAttribute("data-docx-header", "true");
287
+ headerEl.appendChild(mountedHeader);
288
+ }
289
+
290
+ const footerFragment = pickFragmentForKind(
291
+ args.section.properties.footerRefs,
292
+ args.page.headerKind,
293
+ args.registry,
294
+ );
295
+ if (footerFragment) {
296
+ const footerRef = pickRefForKind(
297
+ args.section.properties.footerRefs,
298
+ args.page.headerKind,
299
+ );
300
+ const mountedFooter =
301
+ args.factory?.({
302
+ pageNumber: args.page.pageNumber,
303
+ kind: "ftr",
304
+ fragment: footerFragment.fragment,
305
+ relationshipId: footerRef?.relationshipId ?? null,
306
+ isFirstPage: args.isFirstPage,
307
+ isFirstInSection: args.page.isFirstInSection,
308
+ headerKind: args.page.headerKind,
309
+ }) ?? renderHeaderFooterFragment(footerFragment.fragment);
310
+ mountedFooter.setAttribute("data-docx-footer", "true");
311
+ footerEl.appendChild(mountedFooter);
312
+ }
313
+ }
314
+
315
+ return {
316
+ element: pageEl,
317
+ sectionIndex: args.page.sectionIndex,
318
+ headerKind: args.page.headerKind,
319
+ };
320
+ }
321
+
322
+ function pickRefForKind(
323
+ refs: readonly { type: string; relationshipId: string }[],
324
+ kind: HeaderFooterKind,
325
+ ): { type: string; relationshipId: string } | null {
326
+ const exact = refs.find((r) => r.type === kind);
327
+ if (exact) return exact;
328
+ // Fallback to default when the section doesn't define the requested variant.
329
+ const fallback = refs.find((r) => r.type === "default");
330
+ return fallback ?? null;
331
+ }
332
+
333
+ function pickFragmentForKind(
334
+ refs: readonly { type: string; relationshipId: string }[],
335
+ kind: HeaderFooterKind,
336
+ registry: HeaderFooterRegistry,
337
+ ): { fragment: HeaderFooterFragment } | null {
338
+ const ref = pickRefForKind(refs, kind);
339
+ if (!ref) return null;
340
+ const fragment = registry.byRelationshipId.get(ref.relationshipId);
341
+ if (!fragment) return null;
342
+ return { fragment };
343
+ }
@@ -0,0 +1,234 @@
1
+ import type { FontRegistry, FontResource } from "../fonts/types";
2
+ import type { SectionProperties } from "../model/sections";
3
+ import { dxaToPx } from "./units";
4
+
5
+ export type PageStyle = {
6
+ width: number;
7
+ height: number;
8
+ paddingTop: number;
9
+ paddingRight: number;
10
+ paddingBottom: number;
11
+ paddingLeft: number;
12
+ };
13
+
14
+ export function pageStyleFromSection(props: SectionProperties): PageStyle {
15
+ return {
16
+ width: dxaToPx(props.pageSize.width),
17
+ height: dxaToPx(props.pageSize.height),
18
+ paddingTop: dxaToPx(props.margins.top),
19
+ paddingRight: dxaToPx(props.margins.right),
20
+ paddingBottom: dxaToPx(props.margins.bottom),
21
+ paddingLeft: dxaToPx(props.margins.left),
22
+ };
23
+ }
24
+
25
+ function fontFaceRule(resource: FontResource): string {
26
+ if (resource.source.kind !== "bundled") {
27
+ return "";
28
+ }
29
+ const weight = resource.weight;
30
+ const style = resource.style;
31
+ return [
32
+ "@font-face {",
33
+ ` font-family: "${resource.family}";`,
34
+ ` font-weight: ${weight};`,
35
+ ` font-style: ${style};`,
36
+ ` font-display: swap;`,
37
+ ` src: url("${resource.source.assetUrl}") format("woff2");`,
38
+ "}",
39
+ ].join("\n");
40
+ }
41
+
42
+ export function fontFaceStylesheet(registry: FontRegistry): string {
43
+ const rules: string[] = [];
44
+ for (const set of registry.listVariantSets()) {
45
+ for (const variant of [set.regular, set.bold, set.italic, set.boldItalic]) {
46
+ if (variant === null) continue;
47
+ const rule = fontFaceRule(variant);
48
+ if (rule.length > 0) rules.push(rule);
49
+ }
50
+ }
51
+ return rules.join("\n");
52
+ }
53
+
54
+ export const EDITOR_BASE_CSS = `
55
+ .docx-canvas {
56
+ background: #f1f3f4;
57
+ padding: 24px 0 80px;
58
+ display: flex;
59
+ justify-content: center;
60
+ min-height: 100%;
61
+ }
62
+ .docx-canvas-inner {
63
+ transform-origin: top center;
64
+ transition: transform 120ms ease-out;
65
+ }
66
+ .docx-page-stack {
67
+ position: relative;
68
+ }
69
+ .docx-pages-layer {
70
+ position: relative;
71
+ width: 100%;
72
+ }
73
+ .docx-page {
74
+ background: #ffffff;
75
+ box-shadow: 0 1px 3px rgba(60,64,67,0.15), 0 4px 8px rgba(60,64,67,0.10);
76
+ box-sizing: border-box;
77
+ display: flex;
78
+ flex-direction: column;
79
+ }
80
+ .docx-page-header-band {
81
+ flex-shrink: 0;
82
+ box-sizing: border-box;
83
+ padding: 0;
84
+ overflow: hidden;
85
+ }
86
+ .docx-page-body {
87
+ flex: 1 1 auto;
88
+ }
89
+ .docx-page-footer-band {
90
+ flex-shrink: 0;
91
+ box-sizing: border-box;
92
+ padding: 0;
93
+ overflow: hidden;
94
+ }
95
+ .docx-page-gap {
96
+ display: block;
97
+ width: 100%;
98
+ pointer-events: none;
99
+ user-select: none;
100
+ }
101
+ .docx-editor-mount {
102
+ box-sizing: border-box;
103
+ }
104
+ .docx-editor-content {
105
+ outline: none;
106
+ caret-color: #1a73e8;
107
+ font-feature-settings: "kern", "liga", "calt";
108
+ text-rendering: optimizeLegibility;
109
+ -webkit-font-smoothing: antialiased;
110
+ -moz-osx-font-smoothing: grayscale;
111
+ background: transparent;
112
+ }
113
+ .docx-editor-content::selection,
114
+ .docx-editor-content *::selection {
115
+ background: rgba(166, 206, 255, 0.55);
116
+ }
117
+ .docx-editor-content p { margin: 0; }
118
+ .docx-editor-content table {
119
+ border-collapse: collapse;
120
+ /* auto layout matches Word AutoFit: cells expand to content. PM-table
121
+ conversion passes null colwidth for tblW=auto so this engages. Fixed
122
+ widths still apply via the PM colwidth attribute / inline col widths. */
123
+ table-layout: auto;
124
+ width: auto;
125
+ margin: 8px 0;
126
+ }
127
+ .docx-editor-content td, .docx-editor-content th {
128
+ vertical-align: top;
129
+ position: relative;
130
+ /* Word's TableGrid default: single 0.5pt black on every cell edge.
131
+ OOXML tblBorders/tcBorders parse later — for now this guarantees
132
+ every table renders visible borders rather than appearing as plain
133
+ text columns. Cell-specific border overrides win via specificity. */
134
+ border: 1px solid #000000;
135
+ padding: 4px 8px;
136
+ min-width: 24px;
137
+ }
138
+ .docx-editor-content .selectedCell {
139
+ background: rgba(26, 115, 232, 0.12);
140
+ outline: 1px solid rgba(26, 115, 232, 0.35);
141
+ }
142
+ .docx-editor-content .column-resize-handle {
143
+ position: absolute;
144
+ right: -2px;
145
+ top: 0;
146
+ bottom: 0;
147
+ width: 4px;
148
+ background: #1a73e8;
149
+ pointer-events: none;
150
+ z-index: 20;
151
+ }
152
+ .docx-editor-content.resize-cursor { cursor: ew-resize; cursor: col-resize; }
153
+ .docx-editor-content .ProseMirror-gapcursor { display: none; pointer-events: none; position: absolute; }
154
+ .docx-editor-content .ProseMirror-gapcursor::after {
155
+ content: "";
156
+ display: block;
157
+ position: absolute;
158
+ top: -2px;
159
+ width: 20px;
160
+ border-top: 1px solid #1a73e8;
161
+ }
162
+ .docx-editor-content.ProseMirror-focused .ProseMirror-gapcursor { display: block; }
163
+ .docx-editor-content .docx-template-marker { cursor: default; }
164
+ .docx-placeholder-empty::before {
165
+ content: attr(data-placeholder);
166
+ color: #9aa0a6;
167
+ pointer-events: none;
168
+ position: absolute;
169
+ }
170
+ .ProseMirror-search-match { background-color: rgba(255, 235, 59, 0.45); }
171
+ .ProseMirror-active-search-match { background-color: rgba(255, 152, 0, 0.55); }
172
+
173
+ .docx-image-inline { position: relative; display: inline-block; }
174
+ .docx-image-inline img { display: block; user-select: none; }
175
+ .docx-image-inline:hover .docx-image-resize-handle,
176
+ .docx-image-inline:focus-within .docx-image-resize-handle {
177
+ opacity: 1;
178
+ }
179
+ .docx-image-resize-handle {
180
+ position: absolute;
181
+ width: 10px;
182
+ height: 10px;
183
+ background: #ffffff;
184
+ border: 1px solid #1a73e8;
185
+ border-radius: 50%;
186
+ opacity: 0;
187
+ transition: opacity 100ms ease-out;
188
+ z-index: 10;
189
+ }
190
+ .docx-image-resize-handle--nw { top: -5px; left: -5px; cursor: nwse-resize; }
191
+ .docx-image-resize-handle--ne { top: -5px; right: -5px; cursor: nesw-resize; }
192
+ .docx-image-resize-handle--sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
193
+ .docx-image-resize-handle--se { bottom: -5px; right: -5px; cursor: nwse-resize; }
194
+
195
+ .docx-numbering-marker {
196
+ display: inline-block;
197
+ min-width: 1.5em;
198
+ margin-right: 0.4em;
199
+ text-align: left;
200
+ user-select: none;
201
+ }
202
+ .docx-editor-content [data-list-ilvl="0"] { padding-left: 24px; }
203
+ .docx-editor-content [data-list-ilvl="1"] { padding-left: 48px; }
204
+ .docx-editor-content [data-list-ilvl="2"] { padding-left: 72px; }
205
+ .docx-editor-content [data-list-ilvl="3"] { padding-left: 96px; }
206
+ .docx-editor-content [data-list-ilvl="4"] { padding-left: 120px; }
207
+ .docx-editor-content [data-list-ilvl="5"] { padding-left: 144px; }
208
+ .docx-editor-content [data-list-ilvl="6"] { padding-left: 168px; }
209
+ .docx-editor-content [data-list-ilvl="7"] { padding-left: 192px; }
210
+ .docx-editor-content [data-list-ilvl="8"] { padding-left: 216px; }
211
+
212
+ .docx-page-footnotes {
213
+ flex-shrink: 0;
214
+ padding: 0 96px;
215
+ border-top: 1px solid #dadce0;
216
+ font-size: 10pt;
217
+ color: #3c4043;
218
+ box-sizing: border-box;
219
+ }
220
+ .docx-footnotes__list {
221
+ margin: 8px 0;
222
+ padding-left: 24px;
223
+ }
224
+ .docx-footnote {
225
+ margin-bottom: 4px;
226
+ }
227
+
228
+ .docx-header-footer { color: #5f6368; padding: 8px 96px; box-sizing: border-box; height: 100%; }
229
+ .docx-header-footer--editable { outline: none; cursor: text; }
230
+ .docx-header-footer--editable:focus { color: #1f1f1f; background: rgba(26,115,232,0.04); }
231
+ .docx-page-stack--body-dimmed .docx-editor-content {
232
+ opacity: 0.5;
233
+ }
234
+ `;