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
@@ -20,6 +20,11 @@ const AUTHORED_ADVISORY_SOURCES = new Set([
20
20
  // with its chart laid over the figures it was drawn from.
21
21
  const VISIBLE_DEFECT_CODES = new Set(['drawing_covers_cells', 'drawing_overlap']);
22
22
 
23
+ /** A Word document or workbook written to a brief (design.brief): reviewed with the deck's scored critique. */
24
+ export function briefedDocument(session) {
25
+ return ['docx', 'xlsx'].includes(session?.format) && session?.authoredBrief?.present === true;
26
+ }
27
+
23
28
  export function blocksFinalize(issue, { failOn, authored }) {
24
29
  if (
25
30
  VISIBLE_DEFECT_CODES.has(String(issue?.code || '')) &&
@@ -79,11 +84,19 @@ export async function reviewForFinalize(session, args, cwd, { timedStep, failOn,
79
84
  // only pixels this call produced are new evidence worth sending again.
80
85
  const reviewImages = reviewed?.preview?.reused !== true && Array.isArray(reviewed?._images) ? reviewed._images : [];
81
86
  const review = reviewed ? { ...reviewed } : null;
82
- const pptx = session.format === 'pptx' ? applyPptxAcceptance(session, args, review) : null;
83
- const documentVisualReview = applyDocumentAcceptance(session, args, review);
87
+ // A PDF of designed pages (authoring/pdf-author-action.mjs) is reviewed as the deck it was measured as, and so is a
88
+ // Word document or workbook written to a brief: five scores, a note, and checks from the brief per page.
89
+ const scored = session.authoredFrame === true || briefedDocument(session);
90
+ const deck = session.format === 'pptx' || scored;
91
+ const pptx = deck ? applyPptxAcceptance(session, args, review) : null;
92
+ const documentVisualReview = scored ? null : applyDocumentAcceptance(session, args, review);
84
93
  if (review) delete review._images;
85
94
  const issuesAfter = review?.issuesAfter || [];
86
95
  const blockingIssues = issuesAfter.filter((issue) => blocksFinalize(issue, { failOn, authored }));
96
+ // Its measured audit ran on the deck at author time; an error it found holds the file as it would the deck.
97
+ if (session.authoredFrame === true) {
98
+ blockingIssues.push(...(session.frameAudit?.top || []).filter((issue) => issue.severity === 'error'));
99
+ }
87
100
  if (review && authored) {
88
101
  review.advisoryIssues = issuesAfter.filter(
89
102
  (issue) => !blockingIssues.includes(issue) && ['error', 'warning'].includes(String(issue?.severity || ''))
@@ -4,6 +4,7 @@
4
4
  import { inferPptxSlideRoles, reviewOfficeDesign } from '../../quality/design-review.mjs';
5
5
  import { issue } from '../../quality/assurance-issue.mjs';
6
6
  import { reviewSnapshot } from '../office-actions-inspect.mjs';
7
+ import { documentBriefIssues } from '../../quality/document-brief.mjs';
7
8
 
8
9
  function unavailableDesignReview(session, error) {
9
10
  return {
@@ -64,6 +65,14 @@ export async function reviewQaDesign(session, args) {
64
65
  } catch (error) {
65
66
  designReview = unavailableDesignReview(session, error);
66
67
  }
68
+ // A PDF of designed pages was read against its brief on the working deck at author time (the plan
69
+ // count, the facts' locators); those readings join the PDF's own review.
70
+ if (session.authoredFrame === true && Array.isArray(session.frameDesignIssues) && session.frameDesignIssues.length) {
71
+ designReview = { ...designReview, issues: [...(designReview.issues || []), ...session.frameDesignIssues] };
72
+ }
73
+ // A Word document or workbook written to a brief: its figures against the facts line, its facts against the sources.
74
+ const briefIssues = documentBriefIssues(session.format, currentSnapshot?.document, session.authoredBrief);
75
+ if (briefIssues.length) designReview = { ...designReview, issues: [...(designReview.issues || []), ...briefIssues] };
67
76
  return {
68
77
  designReview,
69
78
  currentSnapshot,
@@ -9,6 +9,7 @@ import { describeOfficeCapabilities } from '../capabilities.mjs';
9
9
  import { applyBatch, closeSession, finalize, issues, qa, render, save, validate } from './office-actions.mjs';
10
10
  import { getOfficeElement, queryOfficeDocument } from './office-actions-read.mjs';
11
11
  import { snapshot } from './office-sessions.mjs';
12
+ import { noteBatchAgainstHtml } from '../authoring/html-source-drift.mjs';
12
13
  import {
13
14
  assertTransactionUnchanged,
14
15
  beginTransaction,
@@ -41,6 +42,8 @@ async function batchAction(session, args, cwd, signal) {
41
42
  __cwd: cwd,
42
43
  ...(args.finalize === true ? { save: true } : {}),
43
44
  });
45
+ const drift = noteBatchAgainstHtml(session, batch);
46
+ if (drift) batch.htmlSource = drift;
44
47
  if (args.finalize !== true) return batch;
45
48
  return {
46
49
  ...(await finalize(
@@ -9,7 +9,7 @@
9
9
  * returns null so the caller falls through to the session-bound dispatch.
10
10
  */
11
11
  import { mkdir } from 'node:fs/promises';
12
- import { dirname } from 'node:path';
12
+ import { dirname, extname } from 'node:path';
13
13
  import { detectMicrosoftOffice } from '../com/com-adapter.mjs';
14
14
  import { pdfOcrReadiness } from '../pdf/pdf-analysis.mjs';
15
15
  import { describeOfficeCapabilities } from '../capabilities.mjs';
@@ -18,6 +18,8 @@ import { unicodeFontPath } from '../pdf/pdf-fonts.mjs';
18
18
  import { inspectOfficeDesignLibrary } from '../design/library/design-library.mjs';
19
19
  import { openCreateOrAttachOffice } from './office-actions-open.mjs';
20
20
  import { authorPptx } from '../authoring/pptx-author-action.mjs';
21
+ import { authorPdf } from '../authoring/pdf-author-action.mjs';
22
+ import { authorHtmlDocument } from '../authoring/html-document-author.mjs';
21
23
  import {
22
24
  FILE_KIND_TO_FORMAT,
23
25
  documentFormat,
@@ -97,7 +99,10 @@ async function describeWithoutSession(args, cwd) {
97
99
  }
98
100
 
99
101
  async function authorAction(args, cwd, dataDir, signal, startedAt) {
100
- const authored = await authorPptx(args, { cwd, dataDir, signal });
102
+ const extension = extname(String(args.path || args.output || '')).toLowerCase();
103
+ const author =
104
+ { '.pdf': authorPdf, '.docx': authorHtmlDocument, '.xlsx': authorHtmlDocument }[extension] || authorPptx;
105
+ const authored = await author(args, { cwd, dataDir, signal });
101
106
  const images = Array.isArray(authored?._images) ? authored._images : [];
102
107
  delete authored._images;
103
108
  const session = authored.session ? sessions.get(authored.session) : null;
@@ -145,7 +145,7 @@ function portableCreateDesignState() {
145
145
  // old file is closed first, never left registered beside the new one (PDF) or
146
146
  // handed back in place of the document the caller asked to write. A session on
147
147
  // a document the user has open is theirs, so the overwrite is refused instead.
148
- async function releaseOverwrittenSession(target, overwrite) {
148
+ export async function releaseOverwrittenSession(target, overwrite) {
149
149
  if (overwrite !== true) return;
150
150
  const existing = officeSessionForDocument(target);
151
151
  if (!existing) return;
@@ -452,6 +452,22 @@ async function createPdfSession(creation, args, cwd) {
452
452
  });
453
453
  }
454
454
 
455
+ // A PDF the author action printed from HTML (authoring/pdf-author-action.mjs)
456
+ // is already on disk; its session is registered as a created PDF's is, so it
457
+ // goes through the same render, review, and finalize.
458
+ export async function registerHtmlPdfSession(args, dataDir, target, { targetExisted, receipt }) {
459
+ const designContext = await resolveOfficeDesignContext({ args, dataDir, target, format: 'pdf', created: true });
460
+ return registerCreatedSession(
461
+ { target, fileKind: documentFileKind(target), format: 'pdf', dataDir, targetExisted, designContext },
462
+ {
463
+ mode: 'portable',
464
+ backend: 'mixdog-pdf',
465
+ designState: portableCreateDesignState(),
466
+ extra: { createReceipt: receipt, ownership: 'owned', visible: false },
467
+ }
468
+ );
469
+ }
470
+
455
471
  async function createTabularSession(creation) {
456
472
  await mkdir(dirname(creation.target), { recursive: true });
457
473
  await createTabular(creation.target);
@@ -571,7 +587,8 @@ function authoredDesignContext(resolved) {
571
587
  export async function createAuthoredSession(args, _cwd, dataDir, target) {
572
588
  const fileKind = documentFileKind(target);
573
589
  const format = documentFormat(target);
574
- if (format !== 'pptx') throw new Error('author currently supports PowerPoint targets only');
590
+ if (!['pptx', 'docx', 'xlsx'].includes(format))
591
+ throw new Error('author writes PowerPoint, Word, Excel, and PDF targets only');
575
592
  const requestedMode = validatePptxAuthorMode(args.mode);
576
593
  const selected = await selectMode(requestedMode, format, target);
577
594
  const designContext = authoredDesignContext(
@@ -1151,7 +1151,7 @@ function drawPageFooters(document, properties, { font, margin, numbering }) {
1151
1151
  });
1152
1152
  }
1153
1153
 
1154
- function applyDocumentProperties(document, properties) {
1154
+ export function applyDocumentProperties(document, properties) {
1155
1155
  if (properties.title != null) document.setTitle(String(properties.title));
1156
1156
  if (properties.author != null) document.setAuthor(String(properties.author));
1157
1157
  if (properties.subject != null) document.setSubject(String(properties.subject));
@@ -69,6 +69,27 @@ export function patchParagraphFormat(xml, properties, numbering = null) {
69
69
  return patchWordFormat(xml, 'p', 'pPr', paragraphFormatXml(properties, numbering));
70
70
  }
71
71
 
72
+ // A style definition's children run name … qFormat, then pPr, rPr, then the table parts; an absent pPr or rPr is
73
+ // opened in its place first, so the patch lands inside the schema's order rather than ahead of the style's name.
74
+ function openStyleProperties(styleXml, tag) {
75
+ if (new RegExp(`<w:${tag}(?:\\s[^>]*)?(?:/>|>)`).test(styleXml)) {
76
+ return styleXml.replace(new RegExp(`<w:${tag}(?:\\s[^>]*)?/>`), `<w:${tag}></w:${tag}>`);
77
+ }
78
+ const before = tag === 'pPr' ? /<w:(?:rPr|tblPr|trPr|tcPr|tblStylePr)\b/ : /<w:(?:tblPr|trPr|tcPr|tblStylePr)\b/;
79
+ const at = before.exec(styleXml)?.index ?? styleXml.lastIndexOf('</w:style>');
80
+ return `${styleXml.slice(0, at)}<w:${tag}></w:${tag}>${styleXml.slice(at)}`;
81
+ }
82
+
83
+ /** A style's paragraph and run formatting patched with the supplied properties only (define_styles). */
84
+ export function patchWordStyle(styleXml, properties) {
85
+ let next = styleXml;
86
+ const paragraph = paragraphFormatXml(properties);
87
+ if (paragraph) next = patchWordFormat(openStyleProperties(next, 'pPr'), 'style', 'pPr', paragraph);
88
+ const run = wordRunProperties(properties);
89
+ if (run) next = patchWordFormat(openStyleProperties(next, 'rPr'), 'style', 'rPr', run);
90
+ return next;
91
+ }
92
+
72
93
  /** Search the first body phrase, across text runs, without touching headers or footers.
73
94
  * Only matching characters change; fields, drawings and breaks are search barriers. */
74
95
  export function formatFirstBodyPhrase(documentXml, find, properties) {
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { access, chmod, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { gunzipSync } from 'node:zlib';
5
5
  import { join } from 'node:path';
6
+ import { withFileLock } from '../../shared/file-lock.mjs';
6
7
 
7
8
  const VERSION = '0.3.0';
8
9
  const MAX_ARCHIVE_BYTES = 160 * 1024 * 1024;
@@ -167,7 +168,10 @@ export async function ensureOoxmlValidator({ dataDir, download = true, signal =
167
168
  reason: `OOXML schema validation is unavailable on ${process.platform}-${process.arch}.`,
168
169
  };
169
170
  }
170
- const paths = cachePaths(dataDir, entry);
171
+ // Test runners share immutable tools, never session files or user data.
172
+ // Normal app calls retain their existing data-directory cache.
173
+ const sharedCache = process.env.MIXDOG_TEST_OOXML_CACHE_DIR;
174
+ const paths = cachePaths(sharedCache || dataDir, entry);
171
175
  if (await usableBinary(paths.binary)) {
172
176
  return { available: true, cached: true, downloaded: false, path: paths.binary, version: VERSION };
173
177
  }
@@ -181,6 +185,23 @@ export async function ensureOoxmlValidator({ dataDir, download = true, signal =
181
185
  };
182
186
  }
183
187
  await mkdir(paths.root, { recursive: true });
188
+ if (sharedCache) {
189
+ return withFileLock(
190
+ join(paths.root, '.install.lock'),
191
+ async () => {
192
+ signal?.throwIfAborted();
193
+ if (await usableBinary(paths.binary)) {
194
+ return { available: true, cached: true, downloaded: false, path: paths.binary, version: VERSION };
195
+ }
196
+ return installValidator(paths, entry, signal);
197
+ },
198
+ { timeoutMs: 120_000 }
199
+ );
200
+ }
201
+ return installValidator(paths, entry, signal);
202
+ }
203
+
204
+ async function installValidator(paths, entry, signal) {
184
205
  const temporary = join(paths.root, `.${entry.binary}.${randomUUID()}.tmp`);
185
206
  try {
186
207
  const archive = await fetchArchive(packageUrl(entry.name), { signal });
@@ -194,14 +194,18 @@ function categoryAxis({
194
194
  reversed = horizontal,
195
195
  size = CHART_TEXT.sheet.body,
196
196
  low = false,
197
+ numberFormat = '',
197
198
  } = {}) {
199
+ // Dated categories (a month column formatted m"월") read in their cells' format on a text axis: auto would turn
200
+ // them into a date scale, and without the linked format Excel labelled the months with their serials (46174).
198
201
  return (
199
202
  `<c:catAx><c:axId val="${CATEGORY_AXIS_ID}"/>` +
200
203
  `<c:scaling><c:orientation val="${reversed ? 'maxMin' : 'minMax'}"/></c:scaling><c:delete val="${hidden ? 1 : 0}"/><c:axPos val="${horizontal ? 'l' : 'b'}"/>` +
204
+ (numberFormat ? `<c:numFmt formatCode="${xmlEncode(numberFormat)}" sourceLinked="1"/>` : '') +
201
205
  `<c:majorTickMark val="none"/><c:minorTickMark val="none"/><c:tickLblPos val="${low ? 'low' : 'nextTo'}"/>` +
202
206
  '<c:spPr><a:ln w="9525"><a:solidFill><a:srgbClr val="C7CBD1"/></a:solidFill></a:ln></c:spPr>' +
203
207
  axisText(size) +
204
- `<c:crossAx val="${VALUE_AXIS_ID}"/><c:crosses val="autoZero"/><c:auto val="1"/>` +
208
+ `<c:crossAx val="${VALUE_AXIS_ID}"/><c:crosses val="autoZero"/><c:auto val="${numberFormat ? 0 : 1}"/>` +
205
209
  '<c:lblAlgn val="ctr"/><c:lblOffset val="100"/><c:noMultiLvlLbl val="0"/></c:catAx>'
206
210
  );
207
211
  }
@@ -288,6 +292,9 @@ export function chartXml({
288
292
  font = '',
289
293
  // CHART_TEXT.sheet or CHART_TEXT.slide.
290
294
  text = CHART_TEXT.sheet,
295
+ // Dated categories: their serials and the cells' date format, written as a number reference Excel reads in that
296
+ // format; the labels in `categories` are the cache's text otherwise.
297
+ categoryDates = null,
291
298
  } = {}) {
292
299
  const family = resolveChartFamily(chartType);
293
300
  if (!family) {
@@ -334,7 +341,7 @@ export function chartXml({
334
341
  size: text.label,
335
342
  pointColors: pointColorsFor(family, entry, rows),
336
343
  }) +
337
- `<c:cat>${stringReference(categoryFormula, rows)}</c:cat>` +
344
+ `<c:cat>${categoryDates ? numberReference(categoryFormula, categoryDates.values, categoryDates.format) : stringReference(categoryFormula, rows)}</c:cat>` +
338
345
  `<c:val>${numberReference(valueFormula, entry.values, entry.numberFormat || valueNumberFormat)}</c:val>` +
339
346
  (family.element === 'lineChart' ? '<c:smooth val="0"/>' : '') +
340
347
  '</c:ser>'
@@ -386,6 +393,7 @@ export function chartXml({
386
393
  reversed,
387
394
  size: text.body,
388
395
  low: negative,
396
+ numberFormat: categoryDates?.format || '',
389
397
  })}${valueAxis({
390
398
  size: text.body,
391
399
  horizontal: family.direction === 'bar',
@@ -808,6 +808,12 @@ export async function setDocxHeaderFooter(zip, op) {
808
808
  };
809
809
  }
810
810
 
811
+ // A separator says the number stands against the total ("3 / 12"): asked for with separator:' / ' and no
812
+ // includeTotal, the footer printed "3" alone. includeTotal:false still keeps the number alone.
813
+ export function pageNumbersIncludeTotal(op) {
814
+ return op.includeTotal === true || (op.includeTotal !== false && Boolean(String(op.separator ?? '').trim()));
815
+ }
816
+
811
817
  // Same vocabulary as set_header_footer: kind names the story (the footer
812
818
  // unless the caller asks for the header), variant names the page.
813
819
  export async function addDocxPageNumbers(zip, op) {
@@ -821,11 +827,11 @@ export async function addDocxPageNumbers(zip, op) {
821
827
  // larger in the portable file than the same footer written through Word.
822
828
  const size = '<w:rPr><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>';
823
829
  const prefix = op.prefix ? `<w:r>${size}<w:t xml:space="preserve">${xmlEncode(op.prefix)} </w:t></w:r>` : '';
824
- const separator =
825
- op.includeTotal === true
826
- ? `<w:r>${size}<w:t xml:space="preserve"> ${xmlEncode(op.separator || '/')} </w:t></w:r>` +
827
- `<w:fldSimple w:instr=" NUMPAGES "><w:r>${size}<w:t>1</w:t></w:r></w:fldSimple>`
828
- : '';
830
+ const includeTotal = pageNumbersIncludeTotal(op);
831
+ const separator = includeTotal
832
+ ? `<w:r>${size}<w:t xml:space="preserve"> ${xmlEncode(String(op.separator || '/').trim() || '/')} </w:t></w:r>` +
833
+ `<w:fldSimple w:instr=" NUMPAGES "><w:r>${size}<w:t>1</w:t></w:r></w:fldSimple>`
834
+ : '';
829
835
  const numbering =
830
836
  `<w:p><w:pPr><w:jc w:val="${alignment}"/><w:rPr><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr></w:pPr>${prefix}` +
831
837
  `<w:fldSimple w:instr=" PAGE "><w:r>${size}<w:t>1</w:t></w:r></w:fldSimple>` +
@@ -857,7 +863,7 @@ export async function addDocxPageNumbers(zip, op) {
857
863
  changed: true,
858
864
  part: written.part,
859
865
  header,
860
- includeTotal: op.includeTotal === true,
866
+ includeTotal,
861
867
  ...(written.replaced ? { replaced: true } : {}),
862
868
  ...(keptStory ? { keptExistingContent: true } : {}),
863
869
  };
@@ -34,7 +34,8 @@ import {
34
34
  } from './portable-docx-xml.mjs';
35
35
  import { docxRevisionTree, flattenDocxRevisions } from './docx-revisions.mjs';
36
36
  import { settleDocxStory } from './docx-runs.mjs';
37
- import { patchParagraphFormat } from './docx-formatting.mjs';
37
+ import { patchParagraphFormat, patchWordStyle } from './docx-formatting.mjs';
38
+ import { documentStyleId } from './portable-docx-styles.mjs';
38
39
  import { anchorPhraseInParagraph, trackedParagraphReplace, trackedParagraphRewrite } from './docx-tracked-edits.mjs';
39
40
 
40
41
  // Word rebuilds a TOC field when the reader updates it; until then the cached
@@ -386,6 +387,79 @@ export async function setDocxDocumentFont(zip, op) {
386
387
  return { op: op.op, changed: true, ...font };
387
388
  }
388
389
 
390
+ // The fields one style takes in define_styles: its face, size, weight, ink, and its paragraph's flow.
391
+ export const DOCX_STYLE_FIELDS = Object.freeze([
392
+ 'name',
393
+ 'nameEastAsia',
394
+ 'size',
395
+ 'bold',
396
+ 'italic',
397
+ 'color',
398
+ 'alignment',
399
+ 'spacingBefore',
400
+ 'spacingAfter',
401
+ 'lineSpacing',
402
+ 'lineSpacingRule',
403
+ 'keepWithNext',
404
+ 'keepTogether',
405
+ ]);
406
+
407
+ /** The fields of every style asked for, checked before anything is written; one message lists every fault. */
408
+ export function docxStyleDefinitions(op) {
409
+ const styles = op.styles;
410
+ if (!styles || typeof styles !== 'object' || Array.isArray(styles) || !Object.keys(styles).length) {
411
+ throw new Error(
412
+ `define_styles needs styles: { "<style name>": { ${DOCX_STYLE_FIELDS.join(', ')} } }, e.g. { "Heading 1": { size: 15, bold: true } }`
413
+ );
414
+ }
415
+ const faults = [];
416
+ for (const [style, fields] of Object.entries(styles)) {
417
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
418
+ faults.push(`${style}: needs an object of fields`);
419
+ continue;
420
+ }
421
+ const unknown = Object.keys(fields).filter((field) => !DOCX_STYLE_FIELDS.includes(field));
422
+ if (unknown.length) faults.push(`${style}: unknown fields ${unknown.join(', ')}`);
423
+ }
424
+ if (faults.length) throw new Error(`define_styles: ${faults.join('; ')}. Fields: ${DOCX_STYLE_FIELDS.join(', ')}.`);
425
+ return Object.entries(styles);
426
+ }
427
+
428
+ /** Word's own styles defined once — the title, headings, body, caption a report is set in — so every paragraph that
429
+ * names the style takes them, and a paragraph the reader adds in Word under that style matches the rest. Set
430
+ * paragraph by paragraph instead, a heading added later came out in Word's blue Calibri. */
431
+ export async function defineDocxStyles(zip, op) {
432
+ const definitions = docxStyleDefinitions(op);
433
+ const defined = [];
434
+ const notFound = [];
435
+ for (const [requested, fields] of definitions) {
436
+ const resolved = await documentStyleId(zip, requested);
437
+ if (!resolved.found) {
438
+ notFound.push(requested);
439
+ continue;
440
+ }
441
+ const styles = await zipText(zip, 'word/styles.xml');
442
+ const id = resolved.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
443
+ const pattern = new RegExp(`<w:style\\b(?=[^>]*\\bw:styleId="${id}")[^>]*>[\\s\\S]*?<\\/w:style>`);
444
+ const style = pattern.exec(styles)?.[0];
445
+ if (!style) {
446
+ notFound.push(requested);
447
+ continue;
448
+ }
449
+ zip.file(
450
+ 'word/styles.xml',
451
+ styles.replace(style, () => patchWordStyle(style, fields))
452
+ );
453
+ defined.push(requested);
454
+ }
455
+ return {
456
+ op: op.op,
457
+ changed: defined.length > 0,
458
+ styles: defined,
459
+ ...(notFound.length ? { styleNotFound: notFound } : {}),
460
+ };
461
+ }
462
+
389
463
  /** Page size, orientation, margins and text columns of one section, keeping what was not asked for. */
390
464
  function pageRequest(properties) {
391
465
  const orientation = String(properties.orientation || '').toLowerCase();
@@ -251,7 +251,15 @@ export function naturalTableColumnWidths(values, properties = {}, available = 0)
251
251
  const headers = tableHeaderRows(properties, rows.length);
252
252
  const gutters = isLayoutTable(properties) ? [] : gutterColumns(properties);
253
253
  const measure = (text, rowIndex, column) =>
254
- measureTextWidth(text, { ...font, bold: headerBold && rowIndex < headers }) * 1.05 +
254
+ measureTextWidth(text, {
255
+ ...font,
256
+ fontSize:
257
+ Number(tableRowStyle(properties, rowIndex)?.fontSize) > 0
258
+ ? Number(tableRowStyle(properties, rowIndex).fontSize)
259
+ : font.fontSize,
260
+ bold: rowBold(properties, rowIndex, rows.length, headerBold && rowIndex < headers),
261
+ }) *
262
+ 1.05 +
255
263
  CELL_PADDING_POINTS +
256
264
  (gutters[column] ? GUTTER_POINTS : 0);
257
265
  return naturalColumnWidths(rows, measure, available);
@@ -284,6 +292,36 @@ function tableHeaderBold(properties, rows) {
284
292
  return rows > 1 && properties?.headerBold !== false && properties?.repeatHeader !== false;
285
293
  }
286
294
 
295
+ // One row's own type (rowStyles[row]: fontSize, color, bold): a stat strip sets its figures at 22 pt and the labels
296
+ // under them at 9 pt in one table, where a single fontSize set the labels at 22 pt too.
297
+ function tableRowStyle(properties, row) {
298
+ const style = Array.isArray(properties?.rowStyles) ? properties.rowStyles[row] : null;
299
+ return style && typeof style === 'object' ? style : null;
300
+ }
301
+
302
+ // The last row is the total when totalRow is set: bold over a rule, so it reads as the sum of the rows above it and
303
+ // not as one more of them.
304
+ const isTotalRow = (properties, row, rows) => properties?.totalRow === true && rows > 1 && row === rows - 1;
305
+ export const TOTAL_ROW_RULE = Object.freeze({ size: 8, color: '374151' });
306
+
307
+ // A row's weight: its own rowStyles bold when given, else the header's or the total's.
308
+ function rowBold(properties, row, rows, header) {
309
+ const own = tableRowStyle(properties, row)?.bold;
310
+ if (typeof own === 'boolean') return own;
311
+ return header || isTotalRow(properties, row, rows);
312
+ }
313
+
314
+ // A row's type properties: the table's, with its own rowStyles size and ink over them.
315
+ function rowTypeProperties(properties, row) {
316
+ const style = tableRowStyle(properties, row);
317
+ if (!style) return properties;
318
+ return {
319
+ ...properties,
320
+ ...(Number(style.fontSize) > 0 ? { fontSize: Number(style.fontSize) } : {}),
321
+ ...(style.color ? { color: style.color } : {}),
322
+ };
323
+ }
324
+
287
325
  const flushCellMargins = (first, last) => {
288
326
  if (!first && !last) return '';
289
327
  const left = first ? '<w:left w:w="0" w:type="dxa"/>' : '';
@@ -305,12 +343,15 @@ export function wordTableXml(operation, { available = 0 } = {}) {
305
343
  [];
306
344
  const heights = operation.properties?.rowHeights || [];
307
345
  const justifications = (operation.properties?.columnAlignments || []).map(wordJustification);
308
- const runProperties = wordTableRunProperties(operation.properties);
309
346
  // The header row is set apart by weight, on both backends, unless the caller
310
347
  // says otherwise; a header a reader cannot tell from the data is not one.
311
348
  const headerBold = tableHeaderBold(operation.properties, rows);
312
- const headerRunProperties = headerBold ? wordTableRunProperties(operation.properties, { bold: true }) : runProperties;
313
349
  const headers = tableHeaderRows(operation.properties, rows);
350
+ const rowRunProperties = (row) =>
351
+ wordTableRunProperties(rowTypeProperties(operation.properties, row), {
352
+ bold: rowBold(operation.properties, row, rows, headerBold && row < headers),
353
+ });
354
+ const totalBorder = `<w:tcBorders><w:top w:val="single" w:sz="${TOTAL_ROW_RULE.size}" w:space="0" w:color="${TOTAL_ROW_RULE.color}"/></w:tcBorders>`;
314
355
  const gutters = layout ? [] : gutterColumns(operation.properties);
315
356
  // A page break never strands a table's edge: the header travels with the first two rows and the last two rows
316
357
  // travel together (widow and orphan control, row by row) — a three-row table used to leave its last row alone at
@@ -319,7 +360,8 @@ export function wordTableXml(operation, { available = 0 } = {}) {
319
360
  // caption): gluing every row instead carried a 25-row table whole to the next page and left half a page blank.
320
361
  const keptRow = (row) =>
321
362
  row < rows - 1 ? rows <= 6 || row <= headers || row === rows - 2 : Boolean(operation.properties?.keepWithNext);
322
- const paragraphProperties = (row) => wordTableParagraphProperties(operation.properties, { keepNext: keptRow(row) });
363
+ const paragraphProperties = (row) =>
364
+ wordTableParagraphProperties(rowTypeProperties(operation.properties, row), { keepNext: keptRow(row) });
323
365
  const grid = Array.from(
324
366
  { length: columns },
325
367
  (_, column) => `<w:gridCol${widths[column] ? ` w:w="${pointsToTwips(widths[column])}"` : ''}/>`
@@ -347,14 +389,15 @@ export function wordTableXml(operation, { available = 0 } = {}) {
347
389
  .join('');
348
390
  // Schema order inside pPr: style and spacing before justification.
349
391
  const cellParagraphProperties = `${paragraphProperties(row)}${justifications[column] ? `<w:jc w:val="${justifications[column]}"/>` : ''}`;
350
- const cellRunProperties = row < headers ? headerRunProperties : runProperties;
392
+ const cellRunProperties = rowRunProperties(row);
351
393
  // A row reads from its top: a label beside a two-line note starts on the note's first line, where the
352
394
  // bottom edge put it on the last. The header row sits on its rule (its bottom edge), so a header that wraps
353
395
  // stands on the same line as the ones beside it. The exact line pitch keeps a Latin-only figure and a Hangul
354
396
  // label on one baseline either way; set_table_cell_style verticalAlignment overrides per cell.
355
397
  const margins = layout ? flushCellMargins(column === 0, column === columns - 1) : gutterMargin(gutters[column]);
356
398
  const edge = row < headers && headerBold ? 'bottom' : 'top';
357
- return `<w:tc><w:tcPr>${width}${margins}<w:vAlign w:val="${edge}"/></w:tcPr><w:p>${cellParagraphProperties ? `<w:pPr>${cellParagraphProperties}</w:pPr>` : ''}<w:r>${cellRunProperties ? `<w:rPr>${cellRunProperties}</w:rPr>` : ''}${runs}</w:r></w:p></w:tc>`;
399
+ const rule = isTotalRow(operation.properties, row, rows) ? totalBorder : '';
400
+ return `<w:tc><w:tcPr>${width}${rule}${margins}<w:vAlign w:val="${edge}"/></w:tcPr><w:p>${cellParagraphProperties ? `<w:pPr>${cellParagraphProperties}</w:pPr>` : ''}<w:r>${cellRunProperties ? `<w:rPr>${cellRunProperties}</w:rPr>` : ''}${runs}</w:r></w:p></w:tc>`;
358
401
  }
359
402
  ).join('')}</w:tr>`;
360
403
  }).join('');
@@ -28,6 +28,7 @@ import {
28
28
  } from './portable-docx-edits.mjs';
29
29
  import {
30
30
  addDocxNote,
31
+ defineDocxStyles,
31
32
  editDocxParagraph,
32
33
  fillDocxContentControl,
33
34
  fitDocxTable,
@@ -60,6 +61,7 @@ const DOCUMENT_EDITS = {
60
61
  add_image: addDocxImage,
61
62
  set_page: setDocxPage,
62
63
  set_document_font: setDocxDocumentFont,
64
+ define_styles: defineDocxStyles,
63
65
  insert_table_row: editDocxTableRowsOrColumns,
64
66
  delete_table_row: editDocxTableRowsOrColumns,
65
67
  insert_table_column: editDocxTableRowsOrColumns,
@@ -128,6 +128,8 @@ function tableRecord(block, tableIndex) {
128
128
  path: `/body/tbl[${tableIndex}]/row[${rowIndex + 1}]/cell[${cellIndex + 1}]`,
129
129
  index: cellIndex + 1,
130
130
  text: blockText(cell.xml, 'w:t'),
131
+ // The cell's own type, as the review reads a paragraph's: a stat strip's label set at the figure's 22 pt.
132
+ ...statedRunFont(cell.xml),
131
133
  ...cellMarkup(cell.xml),
132
134
  })),
133
135
  }));
@@ -621,7 +621,7 @@ async function sheetAuditIssues(zip) {
621
621
  }
622
622
 
623
623
  /** What the slides measure: fit, contrast, tracking, spacing, balance, structure, charts. */
624
- async function presentationMetricIssues(zip) {
624
+ async function presentationMetricIssues(zip, options = {}) {
625
625
  const issues = [];
626
626
  const inspected = await inspectPptxTextBoxes(zip);
627
627
  for (const fit of reviewTextBoxFit(inspected.boxes, {
@@ -654,7 +654,10 @@ async function presentationMetricIssues(zip) {
654
654
  // neighbours share, a margin breach, a collision. A defect is cheapest to
655
655
  // answer in the turn that wrote it, not one call later. Contrast stays with
656
656
  // the metrics pass above, which measures it against the resolved surface.
657
- for (const finding of reviewOfficeStructure({ format: 'pptx', document: await snapshotPptx(zip) })) {
657
+ // A PDF's designed sheets (printSheet) are held to the print type floors, not a screen's.
658
+ const document = await snapshotPptx(zip);
659
+ if (options.printSheet) document.printSheet = true;
660
+ for (const finding of reviewOfficeStructure({ format: 'pptx', document })) {
658
661
  if (finding.code === 'low_contrast') continue;
659
662
  issues.push(finding);
660
663
  }
@@ -1060,7 +1063,7 @@ export async function issuesPortableOoxml(path, format, options = {}) {
1060
1063
  issues.push(...(await placeholderIssues(zip, format)));
1061
1064
  issues.push(...(await imagePlacementIssues(zip, format)));
1062
1065
  if (format === 'xlsx') issues.push(...(await sheetAuditIssues(zip)));
1063
- if (format === 'pptx') issues.push(...(await presentationMetricIssues(zip)));
1066
+ if (format === 'pptx') issues.push(...(await presentationMetricIssues(zip, options)));
1064
1067
  issues.push(...packageProvenanceIssues(validation));
1065
1068
  if (format === 'docx') issues.push(...(await documentContentIssues(zip, validation)));
1066
1069
  else if (format === 'xlsx') issues.push(...(await workbookContentIssues(zip, options)));