mixdog 1.0.4 → 1.0.6

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 (158) hide show
  1. package/README.md +117 -434
  2. package/package.json +18 -17
  3. package/scripts/gate-local.mjs +93 -0
  4. package/scripts/lib/gate-scheduler.mjs +24 -0
  5. package/scripts/lib/run-node-tests.mjs +5 -1
  6. package/scripts/release-paths.mjs +22 -1
  7. package/scripts/test-direct.mjs +2 -1
  8. package/scripts/test.mjs +3 -1
  9. package/src/defaults/agents.json +0 -12
  10. package/src/defaults/mixdog-config.template.json +1 -2
  11. package/src/defaults/skills/docx/SKILL.md +7 -4
  12. package/src/defaults/skills/docx/references/html.md +46 -0
  13. package/src/defaults/skills/docx/references/native-authoring.md +18 -4
  14. package/src/defaults/skills/pdf/SKILL.md +10 -2
  15. package/src/defaults/skills/pdf/references/html.md +64 -0
  16. package/src/defaults/skills/pptx/references/html.md +1 -1
  17. package/src/defaults/skills/xlsx/SKILL.md +7 -3
  18. package/src/defaults/skills/xlsx/references/html.md +40 -0
  19. package/src/defaults/skills/xlsx/references/report-design.md +21 -4
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -3
  22. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +6 -5
  24. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +6 -3
  25. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +6 -2
  26. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +50 -9
  27. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +28 -1
  28. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +4 -1
  29. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +34 -8
  30. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +10 -2
  31. package/src/runtime/agent/orchestrator/session/transcript-restore/agent-envelope.mjs +1 -1
  32. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +1 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -1
  34. package/src/runtime/attachments/pdf-extract.mjs +7 -3
  35. package/src/runtime/computer-bridge/client.mjs +1 -1
  36. package/src/runtime/memory/index.mjs +4 -15
  37. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  38. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +0 -2
  39. package/src/runtime/memory/lib/cycle-scheduler/backlog-probe.mjs +3 -11
  40. package/src/runtime/memory/lib/cycle-scheduler/health-ledger.mjs +1 -3
  41. package/src/runtime/memory/lib/cycle-scheduler/scheduled-enqueue.mjs +1 -11
  42. package/src/runtime/memory/lib/cycle-scheduler/tick-loop.mjs +0 -2
  43. package/src/runtime/memory/lib/cycle-scheduler.mjs +4 -13
  44. package/src/runtime/memory/lib/cycle-signatures.mjs +0 -6
  45. package/src/runtime/memory/lib/cycle1/cycle1-rows.mjs +1 -1
  46. package/src/runtime/memory/lib/cycle1/cycle1-window.mjs +2 -2
  47. package/src/runtime/memory/lib/embedding-reindex.mjs +1 -1
  48. package/src/runtime/memory/lib/http-router/lifecycle-routes.mjs +0 -1
  49. package/src/runtime/memory/lib/memory-action-handlers/cycle-actions.mjs +7 -52
  50. package/src/runtime/memory/lib/memory-action-handlers/cycle-backfill-action.mjs +6 -22
  51. package/src/runtime/memory/lib/memory-action-handlers/cycle-rebuild-action.mjs +10 -17
  52. package/src/runtime/memory/lib/memory-action-handlers/maintenance-actions.mjs +1 -3
  53. package/src/runtime/memory/lib/memory-action-handlers/manage-actions.mjs +2 -4
  54. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -4
  55. package/src/runtime/memory/lib/memory-cycle-requests.mjs +1 -1
  56. package/src/runtime/memory/lib/{memory-cycle2-shared.mjs → memory-cycle-shared.mjs} +14 -16
  57. package/src/runtime/memory/lib/memory-cycle.mjs +3 -2
  58. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  59. package/src/runtime/memory/lib/memory-embed.mjs +14 -1
  60. package/src/runtime/memory/lib/memory-ops-policy.mjs +8 -11
  61. package/src/runtime/memory/lib/memory-schema/entries.mjs +0 -1
  62. package/src/runtime/memory/lib/memory.mjs +2 -6
  63. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  64. package/src/runtime/memory/lib/query-handlers.mjs +1 -1
  65. package/src/runtime/memory/lib/query-hybrid-search.mjs +1 -1
  66. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +0 -6
  67. package/src/runtime/memory/lib/recall-format.mjs +1 -1
  68. package/src/runtime/office/authoring/docx-html-build.mjs +375 -0
  69. package/src/runtime/office/authoring/docx-html-measure.mjs +561 -0
  70. package/src/runtime/office/authoring/docx-html-runner.mjs +102 -0
  71. package/src/runtime/office/authoring/html-browser.mjs +61 -0
  72. package/src/runtime/office/authoring/html-document-author.mjs +181 -0
  73. package/src/runtime/office/authoring/html-source-drift.mjs +36 -0
  74. package/src/runtime/office/authoring/pdf-author-action.mjs +266 -0
  75. package/src/runtime/office/authoring/pdf-html-charts.mjs +379 -0
  76. package/src/runtime/office/authoring/pdf-html-runner.mjs +317 -0
  77. package/src/runtime/office/authoring/pptx-author-action.mjs +44 -22
  78. package/src/runtime/office/authoring/pptx-author-session.mjs +12 -6
  79. package/src/runtime/office/authoring/pptx-html-build.mjs +23 -4
  80. package/src/runtime/office/authoring/pptx-html-measure.mjs +38 -44
  81. package/src/runtime/office/authoring/pptx-html-runner.mjs +18 -3
  82. package/src/runtime/office/authoring/pptx-receipt.mjs +39 -31
  83. package/src/runtime/office/authoring/pptx-review-artifacts.mjs +9 -1
  84. package/src/runtime/office/authoring/pptx-script-runner.mjs +6 -2
  85. package/src/runtime/office/authoring/xlsx-html-build.mjs +365 -0
  86. package/src/runtime/office/authoring/xlsx-html-measure.mjs +257 -0
  87. package/src/runtime/office/authoring/xlsx-html-runner.mjs +139 -0
  88. package/src/runtime/office/capabilities-catalog.mjs +8 -0
  89. package/src/runtime/office/capabilities-signatures.mjs +18 -6
  90. package/src/runtime/office/capabilities.mjs +26 -1
  91. package/src/runtime/office/com/office-com-host.ps1 +133 -17
  92. package/src/runtime/office/core/office-actions-batch.mjs +16 -1
  93. package/src/runtime/office/core/office-actions-inspect.mjs +3 -2
  94. package/src/runtime/office/core/office-actions-lifecycle.mjs +6 -2
  95. package/src/runtime/office/core/office-actions-render.mjs +4 -1
  96. package/src/runtime/office/core/office-finalize/review-stage.mjs +15 -2
  97. package/src/runtime/office/core/office-qa/design-review-stage.mjs +9 -0
  98. package/src/runtime/office/core/office-session-dispatch.mjs +3 -0
  99. package/src/runtime/office/core/office-sessionless-actions.mjs +7 -2
  100. package/src/runtime/office/core/office-sessions.mjs +19 -2
  101. package/src/runtime/office/pdf/pdf-writer.mjs +1 -1
  102. package/src/runtime/office/portable/docx-formatting.mjs +21 -0
  103. package/src/runtime/office/portable/ooxml-validator.mjs +22 -1
  104. package/src/runtime/office/portable/portable-chart.mjs +10 -2
  105. package/src/runtime/office/portable/portable-docx-edits.mjs +12 -6
  106. package/src/runtime/office/portable/portable-docx-operations.mjs +75 -1
  107. package/src/runtime/office/portable/portable-docx-xml.mjs +49 -6
  108. package/src/runtime/office/portable/portable-docx.mjs +2 -0
  109. package/src/runtime/office/portable/portable-snapshot-docx.mjs +2 -0
  110. package/src/runtime/office/portable/portable-validation.mjs +6 -3
  111. package/src/runtime/office/portable/portable-xlsx-cell-values.mjs +87 -0
  112. package/src/runtime/office/portable/portable-xlsx-charts.mjs +55 -13
  113. package/src/runtime/office/portable/portable-xlsx-sheet-edits.mjs +1 -1
  114. package/src/runtime/office/portable/xlsx-contract.mjs +82 -3
  115. package/src/runtime/office/quality/assurance-checklist.mjs +2 -2
  116. package/src/runtime/office/quality/assurance-rendered.mjs +27 -1
  117. package/src/runtime/office/quality/assurance-structure-docx.mjs +44 -0
  118. package/src/runtime/office/quality/assurance-structure-pptx.mjs +24 -6
  119. package/src/runtime/office/quality/assurance-structure-xlsx.mjs +50 -1
  120. package/src/runtime/office/quality/design-review-critique.mjs +3 -2
  121. package/src/runtime/office/quality/document-brief.mjs +44 -0
  122. package/src/runtime/office/quality/inline-audit.mjs +7 -2
  123. package/src/runtime/office/quality/quality-pipeline.mjs +9 -1
  124. package/src/runtime/office/tool-defs.mjs +2 -2
  125. package/src/runtime/shared/llm/quota-value-estimate.mjs +317 -0
  126. package/src/runtime/shared/llm/usage-ledger-quota.mjs +266 -63
  127. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +35 -0
  128. package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
  129. package/src/runtime/shared/llm/usage-ledger.mjs +10 -1
  130. package/src/runtime/shared/provider-accounts.mjs +11 -1
  131. package/src/runtime/shared/statusline/statusline-segments.mjs +4 -4
  132. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  133. package/src/runtime/shared/wait-until.test-support.mjs +33 -0
  134. package/src/session-runtime/boot/apis.mjs +1 -1
  135. package/src/session-runtime/services/channel-admin.mjs +4 -3
  136. package/src/session-runtime/services/usage-stats-model.mjs +22 -0
  137. package/src/session-runtime/setup-tool/settings-contract.mjs +6 -1
  138. package/src/session-runtime/turn/session-ops.mjs +15 -9
  139. package/src/session-runtime/usage-stats-api.mjs +34 -2
  140. package/src/standalone/agent-dispatch-broker.mjs +1 -1
  141. package/src/standalone/local-session-runtime.mjs +488 -0
  142. package/src/standalone/session-protocol.mjs +1 -0
  143. package/src/standalone/session-runtime-inline-host.mjs +1 -1
  144. package/src/tui/hooks/useSession.mjs +1 -1
  145. package/src/tui/session/session-api/commands.mjs +1 -1
  146. package/src/tui/session/session-api/integrations.mjs +3 -0
  147. package/src/tui/session/session-flow/auto-clear.mjs +6 -7
  148. package/src/tui/session/tool-card-results.mjs +1 -1
  149. package/src/tui/session/turn.mjs +5 -0
  150. package/src/tui/session-local.mjs +3 -488
  151. package/src/tui/session.mjs +1 -1
  152. package/src/ui/statusline-agents.mjs +0 -1
  153. package/src/rules/agent/41-cycle2-agent.md +0 -24
  154. package/src/runtime/memory/lib/cycle-scheduler/cycle2-runs.mjs +0 -120
  155. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +0 -110
  156. package/src/runtime/memory/lib/memory-cycle2-quarantine.mjs +0 -99
  157. package/src/runtime/memory/lib/memory-cycle2-review.mjs +0 -240
  158. package/src/runtime/memory/lib/memory-cycle2.mjs +0 -191
@@ -0,0 +1,139 @@
1
+ // The `author` action's Excel input: lay the HTML's <section data-sheet> pages out in a local browser, read each back
2
+ // as a grid (xlsx-html-measure.mjs), photograph pictures and each section for the review, and write the .xlsx.
3
+ import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ import { htmlFailure, launchHtmlBrowser, photographCaptures, runInBrowser } from './html-browser.mjs';
9
+ import { iconTable, injectIcons } from './pptx-html-measure.mjs';
10
+ import { extractXlsxSheets } from './xlsx-html-measure.mjs';
11
+ import { drawDataCharts } from './pdf-html-charts.mjs';
12
+ import { buildXlsxFromSheets } from './xlsx-html-build.mjs';
13
+
14
+ /**
15
+ * Runs inside the page: a chart whose data-range names table cells by id (`{mh}:{m9},{wh}:{w9}`, each area one column
16
+ * from its header down, the first the categories) takes the labels and values those cells show, so the reference
17
+ * draws the chart the workbook will; a chart with values of its own, or a range not written that way, is left alone.
18
+ */
19
+ function fillRangeCharts() {
20
+ for (const element of document.querySelectorAll('[data-chart][data-range]')) {
21
+ let spec;
22
+ try {
23
+ spec = JSON.parse(element.dataset.chart);
24
+ } catch {
25
+ continue;
26
+ }
27
+ if (spec.values || spec.series) continue;
28
+ const columns = element.dataset.range.split(',').map((area) => {
29
+ const ids = [...area.matchAll(/\{([A-Za-z][\w-]*)\}/g)].map((match) => document.getElementById(match[1]));
30
+ const [from, to] = ids;
31
+ if (ids.length !== 2 || !from || !to || from.cellIndex === undefined) return null;
32
+ const table = from.closest('table');
33
+ if (!table || table !== to.closest('table') || from.cellIndex !== to.cellIndex) return null;
34
+ const rows = [...table.rows].slice(from.parentElement.rowIndex, to.parentElement.rowIndex + 1);
35
+ return rows.map((row) => (row.cells[from.cellIndex]?.innerText || '').trim());
36
+ });
37
+ if (columns.length < 2 || columns.some((column) => !column || column.length !== columns[0].length)) continue;
38
+ const number = (text) => Number(String(text).replace(/[^\d.-]/g, '')) || 0;
39
+ const [labels, ...values] = columns;
40
+ element.dataset.chart = JSON.stringify({
41
+ ...spec,
42
+ labels: labels.slice(1),
43
+ ...(values.length === 1
44
+ ? { values: values[0].slice(1).map(number) }
45
+ : { series: values.map((column) => ({ name: column[0], values: column.slice(1).map(number) })) }),
46
+ });
47
+ }
48
+ }
49
+
50
+ /** Artifacts kept beside the workbook: its HTML and the browser's picture of each sheet. */
51
+ export function xlsxHtmlArtifacts(target) {
52
+ return {
53
+ source: `${target}.mixdog-source.html`,
54
+ shot: (sheet) => `${target}.mixdog-html-sheet-${sheet}.png`,
55
+ compare: (page) => `${target}.mixdog-compare-page-${page}.png`,
56
+ };
57
+ }
58
+
59
+ export async function runXlsxHtmlAuthoring(html, output, { target, signal = null, timeoutMs = 90_000 }) {
60
+ const startedAt = performance.now();
61
+ const artifacts = xlsxHtmlArtifacts(target);
62
+ await mkdir(dirname(output), { recursive: true });
63
+ await rm(output, { force: true });
64
+ await writeFile(artifacts.source, html, 'utf8');
65
+ const scratch = await mkdtemp(join(tmpdir(), 'mixdog-xlsx-captures-'));
66
+ let browser;
67
+ try {
68
+ browser = await launchHtmlBrowser('xlsx-html-browser');
69
+ } catch (error) {
70
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
71
+ return htmlFailure(error, startedAt);
72
+ }
73
+ const work = async () => {
74
+ const page = await browser.newPage();
75
+ await page.setViewport({ width: 1920, height: 1200, deviceScaleFactor: 2 });
76
+ await page.goto(pathToFileURL(artifacts.source).href, { waitUntil: 'load' });
77
+ await page.evaluate(injectIcons, iconTable().table, '0 0 24 24');
78
+ await page.evaluate(() => document.fonts.ready);
79
+ const measure = await page.evaluate(extractXlsxSheets);
80
+ const captures = await photographCaptures(page, 'data-xlsx-capture', measure.captures, scratch);
81
+ // The reference each sheet is held against: its section as the browser drew it, cut at its content (a data sheet's
82
+ // section runs the window's width), with its charts drawn — a chart over its cells (data-range="{a}:{b},…") from
83
+ // the text those cells show.
84
+ await page.evaluate(fillRangeCharts);
85
+ await page.evaluate(drawDataCharts);
86
+ const extents = await page.evaluate(() =>
87
+ [...document.querySelectorAll('section[data-sheet]')].map((section) => {
88
+ const box = section.getBoundingClientRect();
89
+ const cs = getComputedStyle(section);
90
+ let right = box.left;
91
+ let bottom = box.top;
92
+ for (const el of section.querySelectorAll('*')) {
93
+ const r = el.getBoundingClientRect();
94
+ if (!r.width || !r.height) continue;
95
+ right = Math.max(right, r.right);
96
+ bottom = Math.max(bottom, r.bottom);
97
+ }
98
+ return {
99
+ x: box.left + window.scrollX,
100
+ y: box.top + window.scrollY,
101
+ width: Math.min(box.width, right - box.left + (parseFloat(cs.paddingRight) || 0)),
102
+ height: Math.min(box.height, bottom - box.top + (parseFloat(cs.paddingBottom) || 0)),
103
+ };
104
+ })
105
+ );
106
+ const htmlPages = [];
107
+ for (const [index, clip] of extents.entries()) {
108
+ const path = artifacts.shot(index + 1);
109
+ await page.screenshot({ path, clip, captureBeyondViewport: true });
110
+ htmlPages.push({ page: index + 1, path });
111
+ }
112
+ return { measure, captures, htmlPages, title: await page.title() };
113
+ };
114
+ let laid;
115
+ try {
116
+ laid = await runInBrowser(browser, { signal, timeoutMs }, work);
117
+ } catch (error) {
118
+ if (signal?.aborted) throw error;
119
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
120
+ return htmlFailure(error, startedAt);
121
+ }
122
+ try {
123
+ const built = await buildXlsxFromSheets(laid.measure, output, { captures: laid.captures, title: laid.title });
124
+ laid.measure.notes.push(...built.notes);
125
+ return {
126
+ ok: true,
127
+ output,
128
+ bytes: (await stat(output)).size,
129
+ htmlSource: artifacts.source,
130
+ htmlPages: laid.htmlPages,
131
+ logs: laid.measure.notes.map((text) => ({ level: 'warn', text })),
132
+ elapsedMs: Math.round(performance.now() - startedAt),
133
+ };
134
+ } catch (error) {
135
+ return htmlFailure(error, startedAt);
136
+ } finally {
137
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
138
+ }
139
+ }
@@ -99,6 +99,7 @@ export const CATALOG = {
99
99
  'set_paragraph_format',
100
100
  'set_font',
101
101
  'set_document_font',
102
+ 'define_styles',
102
103
  'add_image',
103
104
  // Drawn as a picture before either backend sees it (office-actions-batch withDocumentCharts).
104
105
  'add_chart',
@@ -169,6 +170,8 @@ export const CATALOG = {
169
170
  'headerBold',
170
171
  'headerRows',
171
172
  'keepWithNext',
173
+ 'rowStyles',
174
+ 'totalRow',
172
175
  ],
173
176
  tableStyle: ['style', 'alignment', 'columnAlignments', 'columnWidths', 'borders', 'shading'],
174
177
  tableCell: [
@@ -317,6 +320,11 @@ export const CATALOG = {
317
320
  'valueNumberFormat',
318
321
  'dataLabelPosition',
319
322
  'dataLabelColor',
323
+ 'highlight',
324
+ 'mutedColor',
325
+ 'gridlines',
326
+ 'valueAxis',
327
+ 'fontName',
320
328
  ],
321
329
  table: ['name', 'style'],
322
330
  pivot: ['source', 'destination', 'name', 'rows', 'columns', 'values'],
@@ -68,7 +68,7 @@ export const FORMAT_SIGNATURES = {
68
68
  add_table: signature(['values'], ['paragraph', 'rows', 'columns', 'properties'], {
69
69
  propertySets: ['table'],
70
70
  notes:
71
- 'properties.alignment places the table on the page (left, center, right); properties.columnAlignments sets the text of each column (one of left, center, right, justify per column); without it a column of figures (184,200, 2.1%, 2.6억 원) sets right and the rest left. Without style, borders, or shading the table takes a bold header row with a rule under it and hairlines between rows; headerBold:false keeps the header plain. properties.headerRows (default 1) is how many rows the header takes: a two-level header, its group label merged across the columns it spans (merge_table_cells), is headerRows:2, and every header row is set bold on its bottom edge and repeats on a continuation page.',
71
+ 'properties.alignment places the table on the page (left, center, right); properties.columnAlignments sets the text of each column (one of left, center, right, justify per column); without it a column of figures (184,200, 2.1%, 2.6억 원) sets right and the rest left. Without style, borders, or shading the table takes a bold header row with a rule under it and hairlines between rows; headerBold:false keeps the header plain. properties.headerRows (default 1) is how many rows the header takes: a two-level header, its group label merged across the columns it spans (merge_table_cells), is headerRows:2, and every header row is set bold on its bottom edge and repeats on a continuation page. properties.totalRow:true sets the last row as the total (bold over a rule). properties.rowStyles gives rows their own type, one entry per row from the first (null keeps the table\'s): [{ fontSize: 22, color: "1F5E4B" }, { fontSize: 9, color: "6B7280", bold: false }] is a stat strip — figures over their labels in one table.',
72
72
  }),
73
73
  set_table_style: signature(['table', 'properties'], [], {
74
74
  propertySets: ['tableStyle'],
@@ -101,6 +101,10 @@ export const FORMAT_SIGNATURES = {
101
101
  notes:
102
102
  "The document's own face, size, and ink — what every paragraph without its own reads, and what a paragraph added later in Word or by append_text starts from. properties: name, nameEastAsia, size, color.",
103
103
  }),
104
+ define_styles: signature(['styles'], [], {
105
+ notes:
106
+ 'styles maps a style name to its fields — { "Title": { size: 24, bold: true, spacingAfter: 8 }, "Heading 1": { size: 15, bold: true, color: "1F5E4B", spacingBefore: 18, spacingAfter: 6, keepWithNext: true }, "Normal": { lineSpacing: 18, spacingAfter: 8, alignment: "left" }, "Caption": { size: 9, color: "6B7280" } } — fields: name, nameEastAsia, size, bold, italic, color, alignment, spacingBefore, spacingAfter, lineSpacing, lineSpacingRule, keepWithNext, keepTogether. Built-in styles are reached by their English name in any Word language. Define them before the paragraphs: append_text style:"Heading 1" then takes the definition with no properties of its own, and a paragraph the reader adds in Word under that style matches.',
107
+ }),
104
108
  set_font: signature(['find', 'properties'], [], {
105
109
  propertySets: ['font'],
106
110
  notes:
@@ -179,7 +183,7 @@ export const FORMAT_SIGNATURES = {
179
183
  ['section', 'kind', 'variant', 'prefix', 'separator', 'includeTotal', 'alignment'],
180
184
  {
181
185
  notes:
182
- "Writes into the footer unless kind:'header' asks otherwise; variant picks default, first, or even, as for set_header_footer. The number alone, centred, on both backends; prefix ('Page') and includeTotal:true with separator ('/' by default) add to it.",
186
+ "Writes into the footer unless kind:'header' asks otherwise; variant picks default, first, or even, as for set_header_footer. The number alone, centred, on both backends; prefix ('Page') adds to it, and a separator (' / ') or includeTotal:true adds the total ('3 / 12'; includeTotal:false keeps the number alone). A cover without the running line: set_header_footer variant:'first' text:'' (and the same for the footer) after the default ones.",
183
187
  }
184
188
  ),
185
189
  insert_break: signature([], ['paragraph', 'kind'], {
@@ -239,7 +243,11 @@ export const FORMAT_SIGNATURES = {
239
243
  }
240
244
  ),
241
245
  set_cell: signature(['cell', 'value'], ['sheet']),
242
- set_formula: signature(['cell', 'formula'], ['sheet']),
246
+ set_formula: signature(['formula'], ['sheet'], {
247
+ oneOf: [['cell'], ['range']],
248
+ notes:
249
+ 'cell writes one formula. range fills it as Excel does: the formula is written for the top-left cell and every other cell takes it moved — relative references follow the cell, $-pinned ones stay, a table column (Ops[처리량 (건)]) stays — so range:"C6:C9" with =SUMIFS(Ops[처리량 (건)],Ops[월],B6) writes B7, B8, B9 into the rows below.',
250
+ }),
243
251
  set_range: signature(['range', 'values'], ['sheet']),
244
252
  append_row: signature(['values'], ['sheet'], {
245
253
  notes:
@@ -310,11 +318,16 @@ export const FORMAT_SIGNATURES = {
310
318
  'dataLabelPosition',
311
319
  'dataLabelColor',
312
320
  'plotBy',
321
+ 'highlight',
322
+ 'mutedColor',
323
+ 'gridlines',
324
+ 'valueAxis',
325
+ 'fontName',
313
326
  ],
314
327
  {
315
328
  propertySets: ['chart'],
316
329
  notes:
317
- "plotBy:'rows' reads one bounded range the other way — the first row supplies the categories and every other row is a series named by its first cell — for a sheet that grows a column per period. The first source column supplies categories; remaining columns become series. A series that is not beside its categories joins by comma the way Excel reads it (range:'A7:A12,D7:D12', same rows in every area). A source on another sheet names it on every area (range:\"'Calc'!A1:A7,'Calc'!D1:D7\"); sheet is where the frame stands. cell (H2) places the frame's top-left corner on the grid; left/top are points and win when both are given; width/height are points (420 × 260 at F5 reaches about N22), and the print area has to reach past the frame. toColumn (F) with cell ends the frame at that column's right edge in place of width, so a chart spans a table exactly: a column's points depend on the workbook's font (a Korean Excel's is wider), which a width cannot know.",
330
+ "highlight (a 0-based index or a category) sets one bar or slice-free point in the series colour and the rest in mutedColor (C9CED6), so the chart says which one it is about — one series only. gridlines:false and valueAxis:false quiet the plot when every bar carries its value (showValues). fontName sets every text in the chart in the sheet's face (a Korean workbook's Malgun Gothic), where it otherwise takes the workbook default. plotBy:'rows' reads one bounded range the other way — the first row supplies the categories and every other row is a series named by its first cell — for a sheet that grows a column per period. The first source column supplies categories; remaining columns become series. A series that is not beside its categories joins by comma the way Excel reads it (range:'A7:A12,D7:D12', same rows in every area). A source on another sheet names it on every area (range:\"'Calc'!A1:A7,'Calc'!D1:D7\"); sheet is where the frame stands. cell (H2) places the frame's top-left corner on the grid; left/top are points and win when both are given; width/height are points (420 × 260 at F5 reaches about N22), and the print area has to reach past the frame. toColumn (F) with cell ends the frame at that column's right edge in place of width, so a chart spans a table exactly: a column's points depend on the workbook's font (a Korean Excel's is wider), which a width cannot know.",
318
331
  }
319
332
  ),
320
333
  add_conditional_format: signature(
@@ -330,8 +343,7 @@ export const FORMAT_SIGNATURES = {
330
343
  ['range', 'formula1'],
331
344
  ['sheet', 'type', 'operator', 'formula2', 'inputMessage', 'errorMessage'],
332
345
  {
333
- notes:
334
- `type: ${XLSX_VALIDATION_TYPES.join(', ')}. Without type, choices ("a,b,c" or $A$1:$A$9) mean list; other formulas mean custom. Numeric/date/time/textLength rules take operator: ${XLSX_VALIDATION_OPERATORS.join(', ')} (default between). between/notBetween require formula2; other operators use formula1. Example: type:"whole", operator:"greaterThanOrEqual", formula1:"0". Under protect_sheet unlock entry cells with set_style properties:{ locked:false }.`,
346
+ notes: `type: ${XLSX_VALIDATION_TYPES.join(', ')}. Without type, choices ("a,b,c" or $A$1:$A$9) mean list; other formulas mean custom. Numeric/date/time/textLength rules take operator: ${XLSX_VALIDATION_OPERATORS.join(', ')} (default between). between/notBetween require formula2; other operators use formula1. Example: type:"whole", operator:"greaterThanOrEqual", formula1:"0". Under protect_sheet unlock entry cells with set_style properties:{ locked:false }.`,
335
347
  }
336
348
  ),
337
349
  freeze_panes: signature([], ['sheet', 'row', 'column'], {
@@ -4,6 +4,7 @@ import { FIELD_ALIASES, OPERATION_ALIASES, PROPERTY_ALIASES } from './capabiliti
4
4
  import { BACKENDS, CATALOG, COMMON, VIRTUAL_OPERATIONS } from './capabilities-catalog.mjs';
5
5
  import { COMMON_SIGNATURES, FORMAT_SIGNATURES, signature } from './capabilities-signatures.mjs';
6
6
  import { normalizeXlsxValidation } from './portable/xlsx-contract.mjs';
7
+ import { docxStyleDefinitions } from './portable/portable-docx-operations.mjs';
7
8
 
8
9
  export { OFFICE_ACTIONS } from './capabilities-catalog.mjs';
9
10
 
@@ -149,7 +150,10 @@ function operationProperties(catalog, signatureValue, backend) {
149
150
  .map((name) => [
150
151
  name,
151
152
  catalog.properties[name].filter(
152
- (field) => !backend || !signatureValue.propertyBackends[field] || signatureValue.propertyBackends[field].includes(backend)
153
+ (field) =>
154
+ !backend ||
155
+ !signatureValue.propertyBackends[field] ||
156
+ signatureValue.propertyBackends[field].includes(backend)
153
157
  ),
154
158
  ])
155
159
  );
@@ -271,6 +275,14 @@ function applyFieldAliases(format, name, operation) {
271
275
  // handed to a single-cell operation is one, and says which operation writes it.
272
276
  function singleCellRangeFault(batch, name, operation, index, allowed) {
273
277
  const { format, backend } = batch;
278
+ // set_formula takes a range to fill; one cell named that way is still the cell it is.
279
+ if (format === 'xlsx' && name === 'set_formula' && operation.cell === undefined) {
280
+ if (SINGLE_CELL_REFERENCE.test(String(operation.range ?? '').trim())) {
281
+ operation.cell = operation.range;
282
+ delete operation.range;
283
+ }
284
+ return null;
285
+ }
274
286
  if (format !== 'xlsx' || !allowed.has('cell') || allowed.has('range')) return null;
275
287
  if (operation.range === undefined || operation.cell !== undefined) return null;
276
288
  if (SINGLE_CELL_REFERENCE.test(String(operation.range).trim())) {
@@ -404,6 +416,18 @@ function unknownPropertiesFault(batch, name, operation, index, allowedProperties
404
416
  return `${format.toUpperCase()} operation "${name}" at operation ${index + 1} has unknown properties: ${unknownProperties.join(', ')}.${didYouMean(corrections)} ${name} properties: ${[...allowedProperties].join(', ')}. ${describeHint(format, backend, name)}`;
405
417
  }
406
418
 
419
+ // define_styles carries its fields one level down, per style; both backends read them only after this check, so a
420
+ // misspelt field is refused here on either backend instead of being dropped by one of them.
421
+ function docxStyleDefinitionFault(format, name, operation, index) {
422
+ if (format !== 'docx' || name !== 'define_styles' || operation.styles === undefined) return null;
423
+ try {
424
+ docxStyleDefinitions(operation);
425
+ return null;
426
+ } catch (error) {
427
+ return `DOCX operation "define_styles" at operation ${index + 1}: ${error.message}`;
428
+ }
429
+ }
430
+
407
431
  // A Word table has two alignments that read alike: `alignment` places the
408
432
  // table on the page and `columnAlignments` sets the text of each column.
409
433
  // A list written into the first used to be serialized verbatim into the
@@ -490,6 +514,7 @@ function operationContractFaults(batch, operation, index) {
490
514
  unknownFieldsFault(batch, name, operation, index, { allowed, propertyKeys, missing }),
491
515
  unknownPropertiesFault(batch, name, operation, index, propertyKeys),
492
516
  ...docxTableAlignmentFaults(format, name, operation, index),
517
+ docxStyleDefinitionFault(format, name, operation, index),
493
518
  requiredInputFault(batch, name, operation, index, signatureValue, stableTargets),
494
519
  ].filter(Boolean);
495
520
  if (!faults.length && format === 'xlsx' && name === 'add_validation') {
@@ -370,11 +370,23 @@ function Snapshot-Word($doc, $payload) {
370
370
  # The cell's accepted view and its struck-through words, as for a
371
371
  # paragraph, so a redline in a table reads the same way.
372
372
  try {
373
- $accepted = Word-AcceptedText $doc $table.Cell($r, $c).Range $context
373
+ $cellRange = $table.Cell($r, $c).Range
374
+ $accepted = Word-AcceptedText $doc $cellRange $context
375
+ # The cell's type, as the portable reader states it: a stat strip's label set at the figure's size.
376
+ $fontSize = 0
377
+ $fontBold = $false
378
+ try {
379
+ $measured = [double]$cellRange.Font.Size
380
+ if ($measured -gt 0 -and $measured -lt 1000) { $fontSize = $measured }
381
+ $fontBold = [int]$cellRange.Font.Bold -eq -1
382
+ }
383
+ catch {}
374
384
  $cells += [ordered]@{
375
385
  text = ([string]$accepted.text).TrimEnd("`r", "`a")
376
386
  deletedText = ([string]$accepted.deleted).TrimEnd("`r", "`a")
377
387
  tracked = [bool]$accepted.tracked
388
+ size = $fontSize
389
+ bold = $fontBold
378
390
  }
379
391
  }
380
392
  catch { $cells += $null }
@@ -387,6 +399,9 @@ function Snapshot-Word($doc, $payload) {
387
399
  for ($c = 1; $c -le @($rows[$r - 1]).Count; $c++) {
388
400
  $record = @($rows[$r - 1])[$c - 1]
389
401
  $cell = [ordered]@{ path = "/body/tbl[$i]/row[$r]/cell[$c]"; index = $c; text = $(if ($null -eq $record) { $null } else { $record.text }) }
402
+ if ($null -ne $record -and ($record.size -gt 0 -or $record.bold)) {
403
+ $cell.font = [ordered]@{ size = $record.size; bold = [bool]$record.bold }
404
+ }
390
405
  if ($null -ne $record -and $record.tracked) { $cell.tracked = $true }
391
406
  if ($null -ne $record -and $record.deletedText.Length -gt 0) { $cell.deletedText = $record.deletedText }
392
407
  $cells += $cell
@@ -673,10 +688,25 @@ function Excel-FormulaPrecedents([string]$formula, [string]$currentSheet) {
673
688
  return $output
674
689
  }
675
690
 
691
+ # A cell's own horizontal alignment in the portable reader's words; $null for Excel's General (the cell states none),
692
+ # which the portable reader leaves out too.
693
+ function Excel-HorizontalAlignmentName($value) {
694
+ switch ([int]$value) {
695
+ -4131 { return 'left' }
696
+ -4108 { return 'center' }
697
+ -4152 { return 'right' }
698
+ -4130 { return 'justify' }
699
+ 7 { return 'centerContinuous' }
700
+ -4117 { return 'distributed' }
701
+ 5 { return 'fill' }
702
+ default { return $null }
703
+ }
704
+ }
705
+
676
706
  # One cell's style as the snapshot reports it.
677
707
  function Excel-CellStyle($cell) {
678
708
  $font = $cell.Font
679
- return [ordered]@{
709
+ $style = [ordered]@{
680
710
  fontName = [string]$font.Name
681
711
  fontSize = [double]$font.Size
682
712
  bold = [bool]$font.Bold
@@ -685,6 +715,9 @@ function Excel-CellStyle($cell) {
685
715
  fillColor = [double]$cell.Interior.Color
686
716
  numberFormat = Excel-EnglishNumberFormat $cell
687
717
  }
718
+ $alignment = $(try { Excel-HorizontalAlignmentName $cell.HorizontalAlignment } catch { $null })
719
+ if ($alignment) { $style.horizontalAlignment = $alignment }
720
+ return $style
688
721
  }
689
722
 
690
723
  # The style every cell of a block shares, or $null when they differ: Excel answers a mixed range's property with
@@ -706,7 +739,9 @@ function Excel-SharedCellStyle($range) {
706
739
  if ($null -eq $color -or $color -is [System.DBNull]) { return $null }
707
740
  $italic = $font.Italic
708
741
  if ($null -eq $italic -or $italic -is [System.DBNull]) { return $null }
709
- return [ordered]@{
742
+ $horizontal = $range.HorizontalAlignment
743
+ if ($null -eq $horizontal -or $horizontal -is [System.DBNull]) { return $null }
744
+ $style = [ordered]@{
710
745
  fontName = [string]$name
711
746
  fontSize = [double]$size
712
747
  bold = [bool]$bold
@@ -715,6 +750,9 @@ function Excel-SharedCellStyle($range) {
715
750
  fillColor = [double]$fill
716
751
  numberFormat = Excel-EnglishNumberFormat $range ([string]$format)
717
752
  }
753
+ $alignment = Excel-HorizontalAlignmentName $horizontal
754
+ if ($alignment) { $style.horizontalAlignment = $alignment }
755
+ return $style
718
756
  }
719
757
 
720
758
  # The styles of one column's populated cells ($rows: their sheet rows, ascending) into $styles by address. Asking
@@ -1289,16 +1327,7 @@ function Snapshot-ExcelPage($book, $payload) {
1289
1327
  }
1290
1328
  }
1291
1329
  if ($detailed -and $payload.includeStyles) {
1292
- $cell = $range.Cells.Item($r, $c)
1293
- $entry.style = [ordered]@{
1294
- fontName = [string]$cell.Font.Name
1295
- fontSize = [double]$cell.Font.Size
1296
- bold = [bool]$cell.Font.Bold
1297
- italic = [bool]$cell.Font.Italic
1298
- color = [double]$cell.Font.Color
1299
- fillColor = [double]$cell.Interior.Color
1300
- numberFormat = Excel-EnglishNumberFormat $cell
1301
- }
1330
+ $entry.style = Excel-CellStyle $range.Cells.Item($r, $c)
1302
1331
  }
1303
1332
  if ($detailed) { $cells.Add($entry) }
1304
1333
  }
@@ -2986,6 +3015,32 @@ function Invoke-WordOperation($doc, $op) {
2986
3015
  if ($rows -gt 1 -and $props.repeatHeader -ne $false) {
2987
3016
  for ($row = 1; $row -le $headerRows; $row++) { try { $table.Rows.Item($row).HeadingFormat = $true } catch {} }
2988
3017
  }
3018
+ # The last row is the total when totalRow is set: bold over a rule, as the portable writer draws it.
3019
+ if ($props.totalRow -eq $true -and $rows -gt 1) {
3020
+ $total = $table.Rows.Item($rows)
3021
+ $total.Range.Font.Bold = -1
3022
+ $rule = $total.Borders.Item(-1) # wdBorderTop
3023
+ $rule.LineStyle = 1
3024
+ $rule.LineWidth = 8 # wdLineWidth100pt, the portable writer's w:sz 8
3025
+ $rule.Color = Color-Value '374151'
3026
+ }
3027
+ # One row's own type (rowStyles[row]: fontSize, color, bold) over the table's, with the row's exact line
3028
+ # pitch following its size: a stat strip's 22 pt figures over its 9 pt labels in one table.
3029
+ if ($props.rowStyles) {
3030
+ $rowStyles = @($props.rowStyles)
3031
+ for ($row = 1; $row -le [Math]::Min($rows, $rowStyles.Count); $row++) {
3032
+ $style = $rowStyles[$row - 1]
3033
+ if ($null -eq $style) { continue }
3034
+ $rowRange = $table.Rows.Item($row).Range
3035
+ if ($style.fontSize) {
3036
+ $rowRange.Font.Size = [single]$style.fontSize
3037
+ $rowRange.ParagraphFormat.LineSpacingRule = 4
3038
+ $rowRange.ParagraphFormat.LineSpacing = [single]$style.fontSize * 1.3
3039
+ }
3040
+ if ($style.color) { $rowRange.Font.Color = Color-Value ([string]$style.color) }
3041
+ if ($null -ne $style.bold) { $rowRange.Font.Bold = $(if ($style.bold) { -1 } else { 0 }) }
3042
+ }
3043
+ }
2989
3044
  $added = [ordered]@{ op = 'add_table'; changed = $true; table = [int]$table.Index; rows = $rows; columns = $columns }
2990
3045
  if ($styleNotFound.Count) { $added.styleNotFound = $styleNotFound -join ', ' }
2991
3046
  return $added
@@ -3218,6 +3273,27 @@ function Invoke-WordOperation($doc, $op) {
3218
3273
  if ($props.color) { $normal.Font.Color = Color-Value ([string]$props.color) }
3219
3274
  return [ordered]@{ op = 'set_document_font'; changed = $true; name = [string]$normal.Font.Name; nameEastAsia = [string]$normal.Font.NameFarEast; size = [double]$normal.Font.Size }
3220
3275
  }
3276
+ 'define_styles' {
3277
+ # Word's own styles defined once, as the portable writer patches styles.xml: built-in styles by their id
3278
+ # (Word-StyleValue), so a Korean Word's "제목 1" is reached as "Heading 1".
3279
+ if (-not $op.styles) { throw 'define_styles needs styles: { "<style name>": { fields } }' }
3280
+ $defined = @()
3281
+ $notFound = @()
3282
+ foreach ($entry in $op.styles.PSObject.Properties) {
3283
+ $style = $null
3284
+ try { $style = $doc.Styles.Item((Word-StyleValue ([string]$entry.Name))) } catch { $style = $null }
3285
+ if ($null -eq $style) { $notFound += [string]$entry.Name; continue }
3286
+ $fields = $entry.Value
3287
+ Set-WordRunFormat $style $fields
3288
+ $format = $style.ParagraphFormat
3289
+ if ($fields.alignment) { $format.Alignment = Word-ParagraphAlignment $fields.alignment }
3290
+ Set-WordParagraphFlow $format $fields
3291
+ $defined += [string]$entry.Name
3292
+ }
3293
+ $result = [ordered]@{ op = 'define_styles'; changed = $defined.Count -gt 0; styles = $defined }
3294
+ if ($notFound.Count) { $result.styleNotFound = $notFound }
3295
+ return $result
3296
+ }
3221
3297
  'set_font' {
3222
3298
  $range = $doc.Content.Duplicate
3223
3299
  if (-not $op.find) { throw 'set_font requires non-empty find' }
@@ -3455,16 +3531,18 @@ function Invoke-WordOperation($doc, $op) {
3455
3531
  if ($said) { $footer.Range.InsertParagraphAfter() }
3456
3532
  $range = $footer.Range.Paragraphs.Last.Range
3457
3533
  if ($said) { $null = $range.MoveEnd(1, -1) } else { $range = $footer.Range }
3458
- # The same treatment as the portable writer: the number alone, a prefix
3459
- # only when asked for, the total only with includeTotal:true.
3534
+ # The same treatment as the portable writer: the number alone, a prefix only when asked for, and the
3535
+ # total with includeTotal:true or a separator ("3 / 12"; includeTotal:false keeps the number alone).
3460
3536
  $range.Text = $(if ($op.prefix) { ([string]$op.prefix) + ' ' } else { '' })
3461
3537
  $range = $footer.Range
3462
3538
  $range.Collapse(0)
3463
3539
  $null = $footer.Range.Fields.Add($range, -1, 'PAGE', $true)
3464
- if ([bool]$op.includeTotal) {
3540
+ $separatorText = ([string]$op.separator).Trim()
3541
+ $includeTotal = ([bool]$op.includeTotal) -or ($op.includeTotal -ne $false -and $separatorText)
3542
+ if ($includeTotal) {
3465
3543
  $range = $footer.Range
3466
3544
  $range.Collapse(0)
3467
- $range.InsertAfter(' ' + $(if ($null -ne $op.separator) { [string]$op.separator } else { '/' }) + ' ')
3545
+ $range.InsertAfter(' ' + $(if ($separatorText) { $separatorText } else { '/' }) + ' ')
3468
3546
  $range = $footer.Range
3469
3547
  $range.Collapse(0)
3470
3548
  $null = $footer.Range.Fields.Add($range, -1, 'NUMPAGES', $true)
@@ -4384,6 +4462,16 @@ function Apply-ExcelOperation($book, $op) {
4384
4462
  } 'Excel chart series by rows'
4385
4463
  }
4386
4464
  }
4465
+ # Every text in the chart in the sheet's face, where Excel otherwise sets the workbook default. Set before the
4466
+ # title's face: the chart area's font, set after it, put the title back in Excel's regular grey.
4467
+ if ($op.fontName) {
4468
+ try {
4469
+ $font = $chart.ChartArea.Format.TextFrame2.TextRange.Font
4470
+ $font.Name = [string]$op.fontName
4471
+ $font.NameFarEast = [string]$op.fontName
4472
+ }
4473
+ catch {}
4474
+ }
4387
4475
  if ($op.title) {
4388
4476
  $null = Invoke-ExcelComRetry {
4389
4477
  $chart.HasTitle = $true
@@ -4496,6 +4584,34 @@ function Apply-ExcelOperation($book, $op) {
4496
4584
  else {
4497
4585
  0
4498
4586
  }
4587
+ # The quiet plot and the one lit bar, as the portable writer draws them: gridlines:false, valueAxis:false,
4588
+ # highlight (a 0-based index or a category's text) in the series colour with the rest in mutedColor.
4589
+ if ($op.gridlines -eq $false) { try { $chart.Axes(2, 1).HasMajorGridlines = $false } catch {} }
4590
+ if ($op.valueAxis -eq $false) { try { $chart.Axes(2, 1).Delete() } catch {} }
4591
+ if ($null -ne $op.highlight -and [string]$op.highlight -ne '' -and $seriesCount -eq 1 -and -not $slices) {
4592
+ $series = $chart.SeriesCollection().Item(1)
4593
+ $lit = -1
4594
+ if ($op.highlight -is [ValueType]) { $lit = [int]$op.highlight }
4595
+ else {
4596
+ $names = @(@($series.XValues) | ForEach-Object { [string]$_ })
4597
+ $lit = [Array]::IndexOf($names, [string]$op.highlight)
4598
+ }
4599
+ if ($lit -ge 0 -and $lit -lt $pointCount) {
4600
+ $accent = Color-Value $(if ($palette.Count) { [string]$palette[0] } else { '2F6DB5' })
4601
+ $muted = Color-Value $(if ($op.mutedColor) { [string]$op.mutedColor } else { 'C9CED6' })
4602
+ for ($pointIndex = 1; $pointIndex -le $pointCount; $pointIndex++) {
4603
+ try {
4604
+ $point = $series.Points($pointIndex)
4605
+ $pointColor = $(if ($pointIndex -eq $lit + 1) { $accent } else { $muted })
4606
+ $point.Format.Fill.Solid()
4607
+ $point.Format.Fill.ForeColor.RGB = $pointColor
4608
+ # The series' outline was set in the accent; a receded bar keeps no accent edge.
4609
+ $point.Format.Line.ForeColor.RGB = $pointColor
4610
+ }
4611
+ catch {}
4612
+ }
4613
+ }
4614
+ }
4499
4615
  $chartResultValue = [ordered]@{ op = 'add_chart'; changed = $true; name = [string]$shape.Name; series = $seriesCount; categories = $pointCount }
4500
4616
  if ($pageFit) { $chartResultValue.pageFit = $pageFit }
4501
4617
  return $chartResultValue
@@ -13,7 +13,13 @@ import { applyPdfBatch } from '../pdf/pdf-adapter.mjs';
13
13
  import { createChartPdf } from '../pdf/pdf-writer.mjs';
14
14
  import { renderPdfPages } from '../pdf/pdf-render.mjs';
15
15
  import { assertOfficeOperationContracts } from '../capabilities.mjs';
16
- import { quoteUnquotedSheetReferences, validateXlsxOperations } from '../portable/xlsx-contract.mjs';
16
+ import {
17
+ expandFilledFormulas,
18
+ quoteUnquotedSheetReferences,
19
+ validateXlsxOperations,
20
+ } from '../portable/xlsx-contract.mjs';
21
+ import { translateSharedFormula } from '../portable/xlsx-formula-engine.mjs';
22
+ import { documentBrief } from '../quality/document-brief.mjs';
17
23
  import { applyTabularBatch } from './tabular.mjs';
18
24
  import { expandOfficeDesignOperations } from '../design/design-system.mjs';
19
25
  import { createPptxSlideSelection } from '../design/library/design-library.mjs';
@@ -433,6 +439,12 @@ async function prepareBatchOperations(session, args) {
433
439
  session.designRequest = designRequest;
434
440
  session.design = prepared.design;
435
441
  session.designState ||= emptyOfficeDesignState();
442
+ // A Word document or workbook written to a brief (design.brief, the deck's key: value lines) is held to it as an
443
+ // authored deck is: its figures against the facts, and a scored page review at finalize.
444
+ if (['docx', 'xlsx'].includes(session.format) && typeof args.design?.brief === 'string') {
445
+ const brief = documentBrief(args.design.brief);
446
+ if (brief) session.authoredBrief = brief;
447
+ }
436
448
  const baseDir = args.__cwd || dirname(session.target);
437
449
  let operations = Array.isArray(prepared.operations)
438
450
  ? prepared.operations.map((operation) => localizeOperation(operation, baseDir))
@@ -454,6 +466,9 @@ async function prepareBatchOperations(session, args) {
454
466
  operations = await withImportedSlideBackgrounds(session, operations);
455
467
  prepared.scratch = [];
456
468
  operations = await withDocumentCharts(session, operations, prepared.scratch);
469
+ // set_formula over a range is written cell by cell, each formula moved as Excel's fill moves it, before either
470
+ // backend sees it: both then write cells they already write.
471
+ if (session.format === 'xlsx') operations = expandFilledFormulas(operations, translateSharedFormula);
457
472
  if (session.format === 'xlsx' || TABULAR_FORMATS.has(session.format)) validateXlsxOperations(operations);
458
473
  // Excel rejects `My Sheet!A1` outright; the portable writer quotes it from
459
474
  // the sheet list, and an Excel session gets the same courtesy here.
@@ -55,7 +55,8 @@ async function structureIssues(session, args) {
55
55
  if (!read?.document) return [];
56
56
  return reviewOfficeStructure({
57
57
  format: session.format,
58
- document: read.document,
58
+ // A PDF's designed sheets are held to the print type floors (assurance-structure-pptx typeFloors).
59
+ document: session.printSheet ? { ...read.document, printSheet: true } : read.document,
59
60
  auditProfile: args.auditProfile,
60
61
  });
61
62
  } catch {
@@ -292,7 +293,7 @@ async function microsoftOfficeIssues(session, args) {
292
293
  function portableIssues(session, args) {
293
294
  if (session.format === 'pdf') return issuesPdf(session.target, args);
294
295
  if (TABULAR_FORMATS.has(session.format)) return issuesTabular(session.target, session.format, args);
295
- return issuesPortableOoxml(session.target, session.format, args);
296
+ return issuesPortableOoxml(session.target, session.format, session.printSheet ? { ...args, printSheet: true } : args);
296
297
  }
297
298
 
298
299
  export async function issues(session, args = {}) {
@@ -2,7 +2,7 @@
2
2
  // recalculate → review → hold checks → save → validate → close (see
3
3
  // office-finalize/*.mjs for each stage).
4
4
  import { recalculateForReview } from './office-recalculation.mjs';
5
- import { reviewForFinalize } from './office-finalize/review-stage.mjs';
5
+ import { briefedDocument, reviewForFinalize } from './office-finalize/review-stage.mjs';
6
6
  import { finalizeHold, reviewHold } from './office-finalize/holds.mjs';
7
7
  import { closeForFinalize, saveForFinalize, validateForFinalize } from './office-finalize/commit-stage.mjs';
8
8
 
@@ -29,7 +29,11 @@ export async function finalize(session, args, cwd, signal) {
29
29
  session,
30
30
  failOn,
31
31
  stepMetrics,
32
- requiresVisualReview: session.format === 'pptx' && session.designState?.requiresVisualReview === true,
32
+ // A PDF of designed pages is the author's deck on paper and owes the deck's critique.
33
+ requiresVisualReview:
34
+ session.authoredFrame === true ||
35
+ briefedDocument(session) ||
36
+ (session.format === 'pptx' && session.designState?.requiresVisualReview === true),
33
37
  recalculation: await timedStep('recalculation', () => recalculateForReview(session, signal)),
34
38
  };
35
39
  const { recalculation } = context;
@@ -40,6 +40,8 @@ export async function qa(session, args, cwd, { reuseRender = false } = {}) {
40
40
  format: session.format,
41
41
  pageRoles: design.pageRoles,
42
42
  smallWorksheet: session.format === 'xlsx' && isSmallWorksheetDocument(design.currentSnapshot?.document),
43
+ pageCount: preview.pageCount,
44
+ designedPages: session.authoredFrame === true,
43
45
  });
44
46
  const trust = design.currentSnapshot?.trust || session.trustReview || null;
45
47
  const { review, combinedIssuesAfter } = assembleQaReview({
@@ -92,5 +94,6 @@ export async function qa(session, args, cwd, { reuseRender = false } = {}) {
92
94
  export async function render(session, args, cwd) {
93
95
  await recalculateForReview(session, session.activeSignal || null);
94
96
  const preview = await renderOfficePreview(session, args, cwd);
95
- return session.format === 'pptx' ? pptxReviewArtifacts(session, preview) : preview;
97
+ // A PDF of designed pages is reviewed as the deck it was measured as: receipt and contact sheet.
98
+ return session.format === 'pptx' || session.authoredFrame === true ? pptxReviewArtifacts(session, preview) : preview;
96
99
  }