@uniweb/press 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Parses a styled string with inline HTML marks (<strong>, <em>, <u>, <a>)
3
- * into an array of text/link part objects with style flags.
2
+ * Parses a styled string with inline HTML marks (<strong>, <em>, <u>, <a>,
3
+ * <span data-type="math">) into an array of text / link / math part
4
+ * objects with style flags.
4
5
  *
5
6
  * Ported from legacy report-sdk/src/utils.js:116-186, extended to support
6
7
  * <a href="..."> hyperlinks so paragraphs with auto-linked emails and URLs
7
- * produce real hyperlinks in the docx output.
8
+ * produce real hyperlinks in the docx output, and <span data-type="math">
9
+ * so inline math reaches the print adapters as a structured atom rather
10
+ * than being walked as opaque DOM (which turns MathML into raw operator
11
+ * text).
8
12
  *
9
13
  * @param {string} inputString - HTML string with inline marks.
10
- * @returns {Array<{type: string, content: string, bold?: boolean, italics?: boolean, underline?: object, href?: string}>}
14
+ * @returns {Array<{type: string, content?: string, bold?: boolean, italics?: boolean, underline?: object, href?: string, latex?: string, display?: boolean}>}
11
15
  *
12
16
  * @example
13
17
  * parseStyledString('Hello <strong>World</strong>')
@@ -22,7 +26,34 @@
22
26
  * // { type: 'text', content: 'Visit ' },
23
27
  * // { type: 'link', content: 'Example', href: 'https://example.com' }
24
28
  * // ]
29
+ *
30
+ * @example
31
+ * parseStyledString('Let $G$ be a graph') // after semantic-parser wrapping
32
+ * // input: 'Let <span data-type="math" data-latex="G" data-display="false">…mathml…</span> be a graph'
33
+ * // => [
34
+ * // { type: 'text', content: 'Let ' },
35
+ * // { type: 'math', latex: 'G', display: false },
36
+ * // { type: 'text', content: ' be a graph' }
37
+ * // ]
25
38
  */
39
+
40
+ // Decode HTML-attribute entities written by sequence.js's escapeAttr().
41
+ // Inverse of the four replacements there. `&amp;` last so a literal
42
+ // `&amp;quot;` round-trips correctly.
43
+ function decodeAttr(s) {
44
+ return String(s)
45
+ .replace(/&quot;/g, '"')
46
+ .replace(/&lt;/g, '<')
47
+ .replace(/&gt;/g, '>')
48
+ .replace(/&amp;/g, '&')
49
+ }
50
+
51
+ function readAttr(attrs, name) {
52
+ const re = new RegExp(`${name}="([^"]*)"`)
53
+ const m = attrs.match(re)
54
+ return m ? decodeAttr(m[1]) : null
55
+ }
56
+
26
57
  export function parseStyledString(inputString) {
27
58
  const createTextPart = (content, styles) => ({
28
59
  type: 'text',
@@ -48,8 +79,7 @@ export function parseStyledString(inputString) {
48
79
 
49
80
  // Handle <a> tags as links
50
81
  if (tag === 'a' && attrs) {
51
- const hrefMatch = attrs.match(/href="([^"]*)"/)
52
- const href = hrefMatch?.[1]
82
+ const href = readAttr(attrs, 'href')
53
83
  if (href) {
54
84
  result.push({
55
85
  type: 'link',
@@ -61,6 +91,29 @@ export function parseStyledString(inputString) {
61
91
  }
62
92
  }
63
93
 
94
+ // Handle <span data-type="math"> as an inline atom. The inner
95
+ // text is the pre-compiled MathML — discarded here because
96
+ // print adapters consume the LaTeX source instead. Browser-
97
+ // side renderers don't go through parseStyledString; they use
98
+ // dangerouslySetInnerHTML on the original paragraph HTML where
99
+ // the MathML survives intact.
100
+ if (tag === 'span' && attrs) {
101
+ const dataType = readAttr(attrs, 'data-type')
102
+ if (dataType === 'math') {
103
+ const latex = readAttr(attrs, 'data-latex') || ''
104
+ const display = readAttr(attrs, 'data-display') === 'true'
105
+ const id = readAttr(attrs, 'data-id')
106
+ result.push({
107
+ type: 'math',
108
+ latex,
109
+ display,
110
+ ...(id ? { id } : {}),
111
+ })
112
+ lastIndex = offset + match.length
113
+ return
114
+ }
115
+ }
116
+
64
117
  const newStyles = { ...styles }
65
118
  if (tag === 'strong' || tag === 'b') newStyles.bold = true
66
119
  if (tag === 'em' || tag === 'i') newStyles.italics = true
package/src/docx/units.js CHANGED
@@ -73,3 +73,24 @@ export function convertCentimetersToTwip(centimeters) {
73
73
  export function convertPointsToHalfPoints(points) {
74
74
  return Math.round(points * 2)
75
75
  }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Short aliases — for terse JSX. The longer names above remain canonical
79
+ // (and match the docx library's own helpers); these are sugar so foundation
80
+ // code can write `columnWidths={[cm(15), cm(4), cm(1.5), cm(4)]}` instead of
81
+ // `[convertCentimetersToTwip(15), convertCentimetersToTwip(4), …]`.
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Centimeters to twips. Alias of `convertCentimetersToTwip`. */
85
+ export const cm = convertCentimetersToTwip
86
+
87
+ /** Millimeters to twips. Alias of `convertMillimetersToTwip`. */
88
+ export const mm = convertMillimetersToTwip
89
+
90
+ /** Inches to twips. Alias of `convertInchesToTwip`. */
91
+ export const inch = convertInchesToTwip
92
+
93
+ /** Points to twips. There are 20 twips in a point. */
94
+ export function pt(points) {
95
+ return Math.round(points * 20)
96
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @uniweb/press/format — locale-aware text formatters.
3
+ *
4
+ * Stage 6.1 of kb/framework/plans/press-professional-docx.md.
5
+ *
6
+ * The same JSX feeds the React preview and the docx fragment, so
7
+ * these are React components that emit <span data-type="text"> and
8
+ * route through the standard IR walker. Each takes a `value` prop
9
+ * (or `from`/`to` for ranges) plus optional locale + format props,
10
+ * and produces a single text node with the formatted string. They
11
+ * are theme-aware: an active <DocumentProvider> supplies the
12
+ * default locale and (for currency) the default ISO code, so
13
+ * foundations don't have to repeat them at every call site.
14
+ *
15
+ * The motivation: foundations had been writing `String(invoice.due)`
16
+ * which yields `"Sat Feb 28 2026 19:00:00 GMT-0500 …"` whenever
17
+ * YAML eagerly turns an ISO string into a JS Date. The IR layer
18
+ * also has a defensive Date->ISO coercion (src/adapters/docx.js),
19
+ * but the right way to render dates is to choose a format
20
+ * explicitly via these builders.
21
+ */
22
+ import { useDocumentTheme } from '../ThemeContext.js'
23
+
24
+ /**
25
+ * Coerce a value to a JS Date for formatting. Accepts:
26
+ * - Date instance (used as-is)
27
+ * - ISO string ('2026-03-31')
28
+ * - epoch number
29
+ * Returns null on invalid input so the builder renders nothing.
30
+ */
31
+ function toDate(value) {
32
+ if (value == null || value === '') return null
33
+ if (value instanceof Date) {
34
+ return Number.isNaN(value.getTime()) ? null : value
35
+ }
36
+ const d = new Date(value)
37
+ return Number.isNaN(d.getTime()) ? null : d
38
+ }
39
+
40
+ /**
41
+ * Map our small set of format names onto Intl.DateTimeFormat option
42
+ * bags. 'iso' is special-cased — it returns the YYYY-MM-DD form
43
+ * exactly, using UTC components (not local) so a date like
44
+ * `2026-03-31` doesn't shift across timezones.
45
+ */
46
+ const DATE_FORMATS = {
47
+ iso: null, // sentinel — handled before Intl
48
+ short: { year: 'numeric', month: 'numeric', day: 'numeric' },
49
+ medium: { year: 'numeric', month: 'short', day: 'numeric' },
50
+ long: { year: 'numeric', month: 'long', day: 'numeric' },
51
+ full: {
52
+ weekday: 'long',
53
+ year: 'numeric',
54
+ month: 'long',
55
+ day: 'numeric',
56
+ },
57
+ }
58
+
59
+ function formatIso(d) {
60
+ const y = d.getUTCFullYear()
61
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0')
62
+ const day = String(d.getUTCDate()).padStart(2, '0')
63
+ return `${y}-${m}-${day}`
64
+ }
65
+
66
+ function formatDate(value, { locale = 'en-CA', format = 'medium' } = {}) {
67
+ const d = toDate(value)
68
+ if (!d) return ''
69
+ if (format === 'iso') return formatIso(d)
70
+ const opts = DATE_FORMATS[format] || DATE_FORMATS.medium
71
+ try {
72
+ // Force UTC so date-only ISO inputs ('2026-03-31') don't drift
73
+ // by the local timezone — without this, en-CA in EST renders
74
+ // 'Mar 30, 2026' instead of 'Mar 31, 2026'.
75
+ return new Intl.DateTimeFormat(locale, { ...opts, timeZone: 'UTC' }).format(d)
76
+ } catch {
77
+ // Bad locale — fall back to ISO.
78
+ return formatIso(d)
79
+ }
80
+ }
81
+
82
+ function formatDateRange(from, to, opts) {
83
+ const f = formatDate(from, opts)
84
+ const t = formatDate(to, opts)
85
+ if (f && t) return `${f} – ${t}`
86
+ return f || t || ''
87
+ }
88
+
89
+ function formatCurrency(value, { locale = 'en-CA', code = 'CAD' } = {}) {
90
+ if (value == null || value === '') return ''
91
+ const n = typeof value === 'number' ? value : Number(value)
92
+ if (!Number.isFinite(n)) return ''
93
+ try {
94
+ return new Intl.NumberFormat(locale, {
95
+ style: 'currency',
96
+ currency: code,
97
+ maximumFractionDigits: 2,
98
+ }).format(n)
99
+ } catch {
100
+ // Unknown currency code — fall back to plain number with the code.
101
+ return `${n.toFixed(2)} ${code}`
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Inline date — emits a single text node with a formatted date.
107
+ *
108
+ * @param {Object} props
109
+ * @param {Date|string|number} props.value
110
+ * @param {'iso'|'short'|'medium'|'long'|'full'} [props.format='medium']
111
+ * @param {string} [props.locale] - BCP-47. Defaults to theme.locale.
112
+ */
113
+ export function DateText({ value, format = 'medium', locale }) {
114
+ const theme = useDocumentTheme()
115
+ const text = formatDate(value, {
116
+ locale: locale ?? theme.locale,
117
+ format,
118
+ })
119
+ return <span data-type="text">{text}</span>
120
+ }
121
+
122
+ /**
123
+ * Inline date range — emits a single text node with two formatted
124
+ * dates joined by an en dash. When one bound is missing, only the
125
+ * other one renders.
126
+ */
127
+ export function DateRangeText({
128
+ from,
129
+ to,
130
+ period,
131
+ format = 'medium',
132
+ locale,
133
+ }) {
134
+ const theme = useDocumentTheme()
135
+ const f = period?.from ?? from
136
+ const t = period?.to ?? to
137
+ const text = formatDateRange(f, t, {
138
+ locale: locale ?? theme.locale,
139
+ format,
140
+ })
141
+ return <span data-type="text">{text}</span>
142
+ }
143
+
144
+ /**
145
+ * Inline currency — emits a single text node with a locale- and
146
+ * code-aware money amount.
147
+ *
148
+ * @param {Object} props
149
+ * @param {number|string} props.value
150
+ * @param {string} [props.code] - ISO 4217. Defaults to theme.currency.
151
+ * @param {string} [props.locale]
152
+ */
153
+ export function Currency({ value, code, locale }) {
154
+ const theme = useDocumentTheme()
155
+ const text = formatCurrency(value, {
156
+ locale: locale ?? theme.locale,
157
+ code: code ?? theme.currency,
158
+ })
159
+ return <span data-type="text">{text}</span>
160
+ }
161
+
162
+ /**
163
+ * Imperative versions for callers that need a string outside JSX
164
+ * (cell content building, header data, etc.). These don't touch the
165
+ * theme — pass locale/code explicitly.
166
+ */
167
+ export const formatters = {
168
+ date: formatDate,
169
+ dateRange: formatDateRange,
170
+ currency: formatCurrency,
171
+ }
package/src/index.js CHANGED
@@ -17,3 +17,9 @@ export {
17
17
  compileSubtree,
18
18
  compileDocument,
19
19
  } from './compileRegistrations.js'
20
+ export {
21
+ useDocumentTheme,
22
+ resolveThemeColor,
23
+ resolveThemeFont,
24
+ DEFAULT_THEME,
25
+ } from './ThemeContext.js'
@@ -46,6 +46,23 @@ const toInt = (v) => {
46
46
  return Number.isFinite(n) ? n : v
47
47
  }
48
48
 
49
+ /**
50
+ * Parse a comma-separated list of integers (whitespace tolerant).
51
+ * Used by `data-table-column-widths`, where the attribute encodes an
52
+ * array of twip values like "8504,2268,851,2268". Empty / non-numeric
53
+ * tokens are dropped — downstream callers treat an empty array the
54
+ * same as "no column widths declared".
55
+ */
56
+ const parseIntList = (v) => {
57
+ if (typeof v !== 'string') return v
58
+ return v
59
+ .split(',')
60
+ .map((s) => s.trim())
61
+ .filter(Boolean)
62
+ .map((s) => parseInt(s, 10))
63
+ .filter((n) => Number.isFinite(n))
64
+ }
65
+
49
66
  /**
50
67
  * Explicit attribute → IR path mapping. Entries are listed in the same
51
68
  * order as the legacy switch for ease of cross-reference.
@@ -94,6 +111,124 @@ export const attributeMap = {
94
111
  'data-borders-right-size': { path: ['borders', 'right', 'size'] },
95
112
  'data-borders-right-color': { path: ['borders', 'right', 'color'] },
96
113
 
114
+ // ------------------------------------------------------------------
115
+ // Table-cell additions (Stage 1 of the press-professional-docx plan)
116
+ // ------------------------------------------------------------------
117
+
118
+ // Cell shading. `data-shading-fill` is the load-bearing attribute
119
+ // (the solid background color). `data-shading-type` defaults to
120
+ // 'clear' in the adapter so a plain fill JSX prop produces a solid
121
+ // background without callers having to remember the OOXML idiom.
122
+ 'data-shading-fill': { path: ['shading', 'fill'] },
123
+ 'data-shading-color': { path: ['shading', 'color'] },
124
+ 'data-shading-type': { path: ['shading', 'type'] },
125
+
126
+ // Vertical alignment of the cell's content. `top` | `center` | `bottom`.
127
+ // Maps to docx's VerticalAlignTable enum at the adapter layer.
128
+ 'data-valign': { path: ['verticalAlign'] },
129
+
130
+ // Cell column merge: how many adjacent columns this cell spans.
131
+ 'data-grid-span': { path: ['columnSpan'], transform: toInt },
132
+
133
+ // Cell row merge: how many rows this cell spans (vertical merge).
134
+ // The adapter uses docx's `rowSpan` shorthand, which expects only
135
+ // the *starting* cell to declare a count; library handles the
136
+ // merge-continue rows internally.
137
+ 'data-row-span': { path: ['rowSpan'], transform: toInt },
138
+
139
+ // ------------------------------------------------------------------
140
+ // Stage 3 — paragraph polish
141
+ // ------------------------------------------------------------------
142
+
143
+ // Paragraph indentation. All values are twips. `firstLine` and
144
+ // `hanging` are positive; `left` and `right` may be negative.
145
+ 'data-indent-left': { path: ['indent', 'left'], transform: toInt },
146
+ 'data-indent-right': { path: ['indent', 'right'], transform: toInt },
147
+ 'data-indent-firstline': { path: ['indent', 'firstLine'], transform: toInt },
148
+ 'data-indent-hanging': { path: ['indent', 'hanging'], transform: toInt },
149
+
150
+ // Paragraph tab stops. JSON-encoded array of TabStopDefinition objects:
151
+ // [{ position: 6804, type: 'right', leader: 'dot' }, …]
152
+ // Position is in twips; type is left/right/center/decimal/etc;
153
+ // leader is none/dot/hyphen/underscore/middleDot. Foundations
154
+ // typically construct these via the <Paragraph tabStops=…> prop
155
+ // and the unit helpers (cm, mm, inch, pt).
156
+ // Paragraph-level named style ('Title', 'Heading1', 'Body', …).
157
+ // Maps to `<w:pStyle w:val="…"/>` in the docx adapter. Distinct
158
+ // from the run-level `data-style` (default fallthrough), which
159
+ // emits `<w:rStyle>`.
160
+ 'data-paragraph-style': { path: ['paragraphStyle'] },
161
+
162
+ 'data-tab-stops': {
163
+ path: ['tabStops'],
164
+ transform: (v) => {
165
+ if (typeof v !== 'string') return v
166
+ try {
167
+ const parsed = JSON.parse(v)
168
+ return Array.isArray(parsed) ? parsed : v
169
+ } catch {
170
+ return v
171
+ }
172
+ },
173
+ },
174
+
175
+ // TextRun toggles — explicit map entries route the lower-case
176
+ // HTML attribute names to camelCase IR fields the adapter reads.
177
+ 'data-smallcaps': { path: ['smallCaps'], transform: asTrue },
178
+ 'data-allcaps': { path: ['allCaps'], transform: asTrue },
179
+ 'data-strike': { path: ['strike'], transform: asTrue },
180
+
181
+ // Row-level: whether the row repeats as a header on each new page
182
+ // when the table breaks. Presence-only — any truthy value counts.
183
+ 'data-row-header': { path: ['tableHeader'], transform: asTrue },
184
+
185
+ // ------------------------------------------------------------------
186
+ // Table-level options (Stage 1)
187
+ //
188
+ // Stored under the prefixed `tableXxx` namespace so they don't
189
+ // collide with the cell-level `width` / `borders` properties that
190
+ // existed before. `irToTable` reads these on table-typed nodes.
191
+ // ------------------------------------------------------------------
192
+
193
+ // Column widths in twips. Emitted as a comma-separated string on
194
+ // the `<table>` element by Table.jsx; parsed back to an int array
195
+ // here. Used with `tableLayout: 'fixed'` to lock columns; without
196
+ // a fixed layout, Word redistributes columns to fit content.
197
+ 'data-table-column-widths': {
198
+ path: ['tableColumnWidths'],
199
+ transform: parseIntList,
200
+ },
201
+
202
+ // 'fixed' | 'autofit'. Default in the adapter is 'fixed' when
203
+ // tableColumnWidths is set, undefined otherwise (Word's default).
204
+ 'data-table-layout': { path: ['tableLayout'] },
205
+
206
+ // Whole-table width. Distinct from per-cell width. Same { size, type }
207
+ // shape (pct/dxa/auto) as cell widths.
208
+ 'data-table-width-size': { path: ['tableWidth', 'size'] },
209
+ 'data-table-width-type': { path: ['tableWidth', 'type'] },
210
+
211
+ // Table-level default borders. Per-cell borders still win (and
212
+ // already exist via `data-borders-*`); these set the table grid.
213
+ 'data-table-borders-top-style': { path: ['tableBorders', 'top', 'style'] },
214
+ 'data-table-borders-top-size': { path: ['tableBorders', 'top', 'size'] },
215
+ 'data-table-borders-top-color': { path: ['tableBorders', 'top', 'color'] },
216
+ 'data-table-borders-bottom-style': { path: ['tableBorders', 'bottom', 'style'] },
217
+ 'data-table-borders-bottom-size': { path: ['tableBorders', 'bottom', 'size'] },
218
+ 'data-table-borders-bottom-color': { path: ['tableBorders', 'bottom', 'color'] },
219
+ 'data-table-borders-left-style': { path: ['tableBorders', 'left', 'style'] },
220
+ 'data-table-borders-left-size': { path: ['tableBorders', 'left', 'size'] },
221
+ 'data-table-borders-left-color': { path: ['tableBorders', 'left', 'color'] },
222
+ 'data-table-borders-right-style': { path: ['tableBorders', 'right', 'style'] },
223
+ 'data-table-borders-right-size': { path: ['tableBorders', 'right', 'size'] },
224
+ 'data-table-borders-right-color': { path: ['tableBorders', 'right', 'color'] },
225
+ 'data-table-borders-insideh-style': { path: ['tableBorders', 'insideHorizontal', 'style'] },
226
+ 'data-table-borders-insideh-size': { path: ['tableBorders', 'insideHorizontal', 'size'] },
227
+ 'data-table-borders-insideh-color': { path: ['tableBorders', 'insideHorizontal', 'color'] },
228
+ 'data-table-borders-insidev-style': { path: ['tableBorders', 'insideVertical', 'style'] },
229
+ 'data-table-borders-insidev-size': { path: ['tableBorders', 'insideVertical', 'size'] },
230
+ 'data-table-borders-insidev-color': { path: ['tableBorders', 'insideVertical', 'color'] },
231
+
97
232
  'data-image-type': { path: ['imageType'] },
98
233
 
99
234
  'data-floating-horizontalposition-relative': {
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Synthesize OOXML style definitions from a Press theme.
3
+ *
4
+ * Stage 6.0 of kb/framework/plans/press-professional-docx.md.
5
+ *
6
+ * The theme.typography registry is the foundation-facing API:
7
+ *
8
+ * theme.typography.Title = {
9
+ * font: 'heading', // theme key or literal family
10
+ * size: 56, // half-points (28pt)
11
+ * bold: true,
12
+ * color: 'accent', // theme key or hex
13
+ * paragraph: { spacing: { after: 240 } },
14
+ * }
15
+ *
16
+ * docx@9.x is opinionated about where named styles live. It ships
17
+ * built-in styles for Title, Heading1-6, Hyperlink, Strong, and
18
+ * ListParagraph; entries with those ids in the custom `paragraphStyles`
19
+ * array are ignored in favor of the built-ins. The library exposes
20
+ * `default.{title, heading1, …, hyperlink, strong, listParagraph}`
21
+ * slots specifically for overriding the built-ins. We route role
22
+ * specs to the right slot:
23
+ *
24
+ * - 'Body' / 'Normal' -> default.document (doc-wide rPrDefault/pPrDefault)
25
+ * - 'Title' -> default.title
26
+ * - 'Heading1'..'Heading6' -> default.heading1..6
27
+ * - 'Hyperlink' -> default.hyperlink
28
+ * - 'Strong' -> default.strong
29
+ * - 'ListParagraph' -> default.listParagraph
30
+ * - everything else, kind=paragraph -> custom paragraphStyles[]
31
+ * - everything else, kind=character -> custom characterStyles[]
32
+ *
33
+ * Foundations may also pass `paragraphStyles` / `characterStyles`
34
+ * directly through compile options; those merge on top of the
35
+ * synthesized entries (caller wins on style id conflict).
36
+ */
37
+
38
+ import { resolveThemeColor, resolveThemeFont, DEFAULT_THEME } from '../ThemeContext.js'
39
+
40
+ // Roles that map to docx's named built-in slots in `default.*`.
41
+ const BUILT_IN_DEFAULT_SLOTS = {
42
+ Body: 'document',
43
+ Normal: 'document',
44
+ Title: 'title',
45
+ Heading1: 'heading1',
46
+ Heading2: 'heading2',
47
+ Heading3: 'heading3',
48
+ Heading4: 'heading4',
49
+ Heading5: 'heading5',
50
+ Heading6: 'heading6',
51
+ Hyperlink: 'hyperlink',
52
+ Strong: 'strong',
53
+ ListParagraph: 'listParagraph',
54
+ }
55
+
56
+ /**
57
+ * Resolve a single typography-role spec against the theme, producing
58
+ * the `{ paragraph, run }` shape the docx library expects.
59
+ */
60
+ function resolveRoleSpec(spec, theme) {
61
+ const run = {}
62
+ if (spec.bold) run.bold = true
63
+ if (spec.italics) run.italics = true
64
+ if (spec.smallCaps) run.smallCaps = true
65
+ if (spec.allCaps) run.allCaps = true
66
+ if (spec.strike) run.strike = true
67
+ if (spec.size != null) run.size = spec.size
68
+ const color = resolveThemeColor(spec.color, theme)
69
+ if (color) run.color = color
70
+ const font = resolveThemeFont(spec.font, theme)
71
+ if (font) run.font = font
72
+ if (spec.underline) {
73
+ run.underline =
74
+ typeof spec.underline === 'string'
75
+ ? { type: spec.underline }
76
+ : spec.underline
77
+ }
78
+
79
+ const out = {}
80
+ if (Object.keys(run).length) out.run = run
81
+ if (spec.paragraph) out.paragraph = spec.paragraph
82
+ return out
83
+ }
84
+
85
+ /**
86
+ * Build the `styles` block for `new Document({ styles: … })` from a
87
+ * theme.typography registry.
88
+ *
89
+ * @param {Object} theme
90
+ * @param {Object} [overrides]
91
+ * @param {Array} [overrides.paragraphStyles] - merged on top, caller wins.
92
+ * @param {Array} [overrides.characterStyles] - merged on top, caller wins.
93
+ * @returns {{ default: Object, paragraphStyles: Array, characterStyles: Array }}
94
+ */
95
+ export function buildStylePack(theme = DEFAULT_THEME, overrides = {}) {
96
+ const typography = theme?.typography || DEFAULT_THEME.typography
97
+ const kinds = theme?.typographyKinds || DEFAULT_THEME.typographyKinds
98
+
99
+ const defaults = {}
100
+ const paragraphStyles = []
101
+ const characterStyles = []
102
+
103
+ for (const [roleName, spec] of Object.entries(typography)) {
104
+ const resolved = resolveRoleSpec(spec, theme)
105
+ const slot = BUILT_IN_DEFAULT_SLOTS[roleName]
106
+
107
+ if (slot === 'document') {
108
+ // Doc-wide defaults — applies to every paragraph and run.
109
+ // The OOXML <w:rPrDefault>/<w:pPrDefault>. This is what
110
+ // pins the body font (no more Times New Roman fallback).
111
+ defaults.document = {
112
+ ...(resolved.run ? { run: resolved.run } : {}),
113
+ ...(resolved.paragraph ? { paragraph: resolved.paragraph } : {}),
114
+ }
115
+ continue
116
+ }
117
+
118
+ if (slot) {
119
+ // Override a built-in named style (Title, Heading1-6, etc.).
120
+ defaults[slot] = resolved
121
+ continue
122
+ }
123
+
124
+ // Custom role — falls into paragraphStyles or characterStyles.
125
+ const kind = kinds[roleName] || 'character'
126
+ if (kind === 'paragraph') {
127
+ paragraphStyles.push({
128
+ id: roleName,
129
+ name: roleName,
130
+ basedOn: 'Normal',
131
+ next: 'Normal',
132
+ quickFormat: true,
133
+ ...resolved,
134
+ })
135
+ } else {
136
+ characterStyles.push({
137
+ id: roleName,
138
+ name: roleName,
139
+ basedOn: 'Normal',
140
+ quickFormat: true,
141
+ ...(resolved.run ? { run: resolved.run } : {}),
142
+ })
143
+ }
144
+ }
145
+
146
+ // Caller-supplied paragraph/character styles take effect in two
147
+ // ways. Entries with built-in style IDs (Title, Heading1-6,
148
+ // Hyperlink, Strong) route to `default.{slot}` since docx ignores
149
+ // those IDs in the custom `paragraphStyles` / `characterStyles`
150
+ // arrays. Everything else appends/overrides in the array.
151
+ const callerParagraphs = overrides.paragraphStyles || []
152
+ const callerCharacters = overrides.characterStyles || []
153
+
154
+ for (const style of [...callerParagraphs, ...callerCharacters]) {
155
+ const slot = BUILT_IN_DEFAULT_SLOTS[style.id]
156
+ if (!slot) continue
157
+ const { id: _id, name: _name, basedOn: _b, next: _n, quickFormat: _q, run, paragraph } =
158
+ style
159
+ defaults[slot] = {
160
+ ...(run ? { run } : {}),
161
+ ...(paragraph ? { paragraph } : {}),
162
+ }
163
+ }
164
+
165
+ const filteredCallerParagraphs = callerParagraphs.filter(
166
+ (s) => !BUILT_IN_DEFAULT_SLOTS[s.id],
167
+ )
168
+ const filteredCallerCharacters = callerCharacters.filter(
169
+ (s) => !BUILT_IN_DEFAULT_SLOTS[s.id],
170
+ )
171
+
172
+ return {
173
+ default: defaults,
174
+ paragraphStyles: mergeById(paragraphStyles, filteredCallerParagraphs),
175
+ characterStyles: mergeById(characterStyles, filteredCallerCharacters),
176
+ }
177
+ }
178
+
179
+ function mergeById(synthesized, caller) {
180
+ if (!Array.isArray(caller) || caller.length === 0) return synthesized
181
+ const callerById = new Map(caller.map((s) => [s.id, s]))
182
+ const out = synthesized.map((s) =>
183
+ callerById.has(s.id) ? callerById.get(s.id) : s,
184
+ )
185
+ const synthIds = new Set(synthesized.map((s) => s.id))
186
+ for (const s of caller) {
187
+ if (!synthIds.has(s.id)) out.push(s)
188
+ }
189
+ return out
190
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Math equation — non-text inline atom. Emits a marker span the IR
3
+ * walker turns into a `math` IR node. The typst adapter dispatches on
4
+ * the node and emits `#mitex(`<latex>`)` (inline) or `$ #mitex(`<latex>`) $`
5
+ * (display); foundations consuming `outputs.typst` import mitex in
6
+ * their preamble.
7
+ *
8
+ * Inline and block math share the same builder, distinguished by the
9
+ * `display` flag. The optional `id` flows through as data-id so the
10
+ * typst adapter can emit a typst label (`<id>`) for native
11
+ * cross-references.
12
+ *
13
+ * Mirrors `Image` — same role (non-text inline atom), same attribute
14
+ * convention, same dispatch shape on the adapter side.
15
+ */
16
+ export default function Math({ latex, display = false, id, ...props }) {
17
+ const attrs = { 'data-type': 'math' }
18
+ attrs['data-latex'] = latex || ''
19
+ attrs['data-display'] = display ? 'true' : 'false'
20
+ if (id) attrs['data-id'] = id
21
+
22
+ return <span {...attrs} {...props} />
23
+ }