@uniweb/press 0.4.3 → 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.
@@ -23,6 +23,12 @@ import {
23
23
  Table,
24
24
  TableRow,
25
25
  TableCell,
26
+ TableLayoutType,
27
+ VerticalAlign,
28
+ ShadingType,
29
+ Tab as DocxTab,
30
+ TabStopType,
31
+ LeaderType,
26
32
  ExternalHyperlink,
27
33
  InternalHyperlink,
28
34
  ImageRun,
@@ -45,6 +51,7 @@ import {
45
51
  FootnoteReferenceRun,
46
52
  } from 'docx'
47
53
  import { fetchAsset } from '../assets/fetch.js'
54
+ import { buildStylePack } from '../styles/buildStylePack.js'
48
55
 
49
56
  // ============================================================================
50
57
  // Public API
@@ -112,8 +119,13 @@ export async function buildDocument(input, options = {}) {
112
119
 
113
120
  const {
114
121
  paragraphStyles,
122
+ characterStyles,
123
+ theme,
124
+ typography,
115
125
  numbering,
116
126
  pageMargin,
127
+ pageSize,
128
+ pageOrientation,
117
129
  ...documentMetadata
118
130
  } = options
119
131
 
@@ -137,13 +149,27 @@ export async function buildDocument(input, options = {}) {
137
149
  const { loadAsset } = options
138
150
  const children = await convertChildren(sections.flat(), loadAsset)
139
151
 
152
+ const pageOptions = {
153
+ pageNumbers: { start: 1, formatType: NumberFormat.DECIMAL },
154
+ ...(pageMargin ? { margin: pageMargin } : {}),
155
+ }
156
+ // Stage 4: page size + orientation. docx's IPageSizeAttributes
157
+ // requires width and height, so the orientation flag only takes
158
+ // effect when an explicit pageSize is set. Foundations that want
159
+ // orientation should pass pageSize too — pageSizes.A4 / .LETTER
160
+ // are exported presets.
161
+ if (pageSize) {
162
+ pageOptions.size = {
163
+ width: pageSize.width,
164
+ height: pageSize.height,
165
+ ...(pageOrientation ? { orientation: pageOrientation } : {}),
166
+ }
167
+ }
168
+
140
169
  const sectionOptions = {
141
170
  properties: {
142
171
  type: SectionType.CONTINUOUS,
143
- page: {
144
- pageNumbers: { start: 1, formatType: NumberFormat.DECIMAL },
145
- ...(pageMargin ? { margin: pageMargin } : {}),
146
- },
172
+ page: pageOptions,
147
173
  },
148
174
  children,
149
175
  }
@@ -199,8 +225,47 @@ export async function buildDocument(input, options = {}) {
199
225
  sections: [sectionOptions],
200
226
  }
201
227
 
202
- if (paragraphStyles && paragraphStyles.length) {
203
- docOptions.styles = { paragraphStyles }
228
+ // Stage 6.0: synthesise OOXML styles from theme.typography (or an
229
+ // explicit typography registry) and merge with caller-supplied
230
+ // paragraphStyles / characterStyles. The synthesised pack provides
231
+ // document defaults (so every run inherits theme.fonts.body without
232
+ // every TextRun emitting `data-font`), built-in style overrides
233
+ // (Word's Title/Heading1-6 routed through `default.*`), and named
234
+ // styles that builders reference via `<Paragraph role="…">` /
235
+ // `<TextRun role="…">`. Caller styles override on id conflict.
236
+ //
237
+ // We synthesise whenever a theme is supplied, even if its
238
+ // typography is partial — buildStylePack falls back to
239
+ // DEFAULT_THEME entries for missing roles so the docx still gets
240
+ // a useful style pack.
241
+ const stylesTheme = typography
242
+ ? { ...(theme || {}), typography }
243
+ : theme
244
+ if (stylesTheme) {
245
+ const pack = buildStylePack(stylesTheme, {
246
+ paragraphStyles,
247
+ characterStyles,
248
+ })
249
+ const stylesBlock = {}
250
+ if (pack.default && Object.keys(pack.default).length) {
251
+ stylesBlock.default = pack.default
252
+ }
253
+ if (pack.paragraphStyles.length) {
254
+ stylesBlock.paragraphStyles = pack.paragraphStyles
255
+ }
256
+ if (pack.characterStyles.length) {
257
+ stylesBlock.characterStyles = pack.characterStyles
258
+ }
259
+ if (Object.keys(stylesBlock).length) docOptions.styles = stylesBlock
260
+ } else if (
261
+ (paragraphStyles && paragraphStyles.length) ||
262
+ (characterStyles && characterStyles.length)
263
+ ) {
264
+ // Legacy path: caller passed only raw styles, no theme. Pass
265
+ // through verbatim so existing foundations keep working.
266
+ docOptions.styles = {}
267
+ if (paragraphStyles?.length) docOptions.styles.paragraphStyles = paragraphStyles
268
+ if (characterStyles?.length) docOptions.styles.characterStyles = characterStyles
204
269
  }
205
270
 
206
271
  if (numbering && numbering.length) {
@@ -361,7 +426,12 @@ function irToParagraph(node) {
361
426
  if (node.heading) {
362
427
  options.heading = toHeadingLevel(node.heading)
363
428
  }
364
- if (node.style) {
429
+ // Paragraph-level named style (data-paragraph-style on the element,
430
+ // emitted by `<Paragraph role="…">`). Wins over node.style which is
431
+ // the legacy run-level 'Hyperlink' marker.
432
+ if (node.paragraphStyle) {
433
+ options.style = node.paragraphStyle
434
+ } else if (node.style) {
365
435
  options.style = node.style
366
436
  }
367
437
  if (node.alignment) {
@@ -388,6 +458,21 @@ function irToParagraph(node) {
388
458
  const instance = toInt(node.numbering.instance)
389
459
  if (instance != null) options.numbering.instance = instance
390
460
  }
461
+ if (node.indent) {
462
+ const indent = {}
463
+ for (const key of ['left', 'right', 'start', 'end', 'firstLine', 'hanging']) {
464
+ const v = node.indent[key]
465
+ if (v == null) continue
466
+ const n = typeof v === 'string' ? parseInt(v, 10) : v
467
+ if (Number.isFinite(n)) indent[key] = n
468
+ }
469
+ if (Object.keys(indent).length) options.indent = indent
470
+ }
471
+ if (Array.isArray(node.tabStops) && node.tabStops.length) {
472
+ options.tabStops = node.tabStops
473
+ .map(toTabStopDefinition)
474
+ .filter(Boolean)
475
+ }
391
476
 
392
477
  const children = (node.children || []).flatMap(irToInlineChildren)
393
478
 
@@ -416,6 +501,11 @@ function irToInlineChildren(node) {
416
501
  switch (node.type) {
417
502
  case 'text':
418
503
  return irToTextRunPair(node)
504
+ case 'tab':
505
+ // Stage 3: <Tab/> inline builder. Emits a docx Tab element
506
+ // that interacts with the paragraph's tabStops. Wrapped in
507
+ // a TextRun so the OOXML parent is <w:r>.
508
+ return [new TextRun({ children: [new DocxTab()] })]
419
509
  case 'externalHyperlink':
420
510
  return [irToExternalHyperlink(node)]
421
511
  case 'internalHyperlink':
@@ -437,6 +527,14 @@ function irToInlineChildren(node) {
437
527
  return node.footnoteId
438
528
  ? [new FootnoteReferenceRun(node.footnoteId)]
439
529
  : []
530
+ case 'math': {
531
+ // v1 fallback: emit the LaTeX source as plain text. Better
532
+ // than the MathML soup that was leaking before this case
533
+ // existed (`<mo>…</mo>` operator names appearing as letters
534
+ // in the document). A faithful OMML emitter is tracked in
535
+ // kb/framework/plans/math-print-adapters.md §"Future work".
536
+ return [new TextRun({ text: node.latex || '' })]
537
+ }
440
538
  default:
441
539
  // Unknown inline: try text extraction, then recurse children.
442
540
  if (node.content) {
@@ -490,6 +588,21 @@ function irToTextRunPair(node) {
490
588
  if (node.italics === 'true' || node.italics === true) options.italics = true
491
589
  if (node.underline) options.underline = node.underline
492
590
  if (node.style) options.style = node.style
591
+ // Stage 1 of press-professional-docx: branded text needs color/size/font.
592
+ // The IR's color/size/font come from the default fallthrough rule on
593
+ // unknown data-* attributes (data-color, data-size, data-font set by
594
+ // the TextRun builder). docx@9.x expects: color = hex without '#',
595
+ // size = half-points (28pt = 56), font = family name string.
596
+ if (node.color) options.color = node.color
597
+ if (node.size != null) {
598
+ const sz = typeof node.size === 'string' ? parseInt(node.size, 10) : node.size
599
+ if (Number.isFinite(sz)) options.size = sz
600
+ }
601
+ if (node.font) options.font = node.font
602
+ // Stage 3: smallCaps / allCaps / strike — boolean run formatting.
603
+ if (node.smallCaps === 'true' || node.smallCaps === true) options.smallCaps = true
604
+ if (node.allCaps === 'true' || node.allCaps === true) options.allCaps = true
605
+ if (node.strike === 'true' || node.strike === true) options.strike = true
493
606
 
494
607
  result.push(new TextRun(options))
495
608
  return result
@@ -519,14 +632,53 @@ function irToTable(node) {
519
632
  const rows = (node.children || [])
520
633
  .filter((child) => child.type === 'tableRow')
521
634
  .map(irToTableRow)
522
- return new Table({ rows })
635
+
636
+ const options = { rows }
637
+
638
+ // Column widths (twips array). Parsed from `data-table-column-widths`.
639
+ if (Array.isArray(node.tableColumnWidths) && node.tableColumnWidths.length) {
640
+ options.columnWidths = node.tableColumnWidths
641
+ }
642
+
643
+ // Layout. Default to FIXED whenever explicit column widths are
644
+ // present — Word's autofit otherwise redistributes the columns to
645
+ // fit content, undoing the foundation's layout intent. Foundations
646
+ // that want autofit can opt out with `layout="autofit"`.
647
+ if (node.tableLayout) {
648
+ options.layout =
649
+ node.tableLayout === 'autofit'
650
+ ? TableLayoutType.AUTOFIT
651
+ : TableLayoutType.FIXED
652
+ } else if (options.columnWidths) {
653
+ options.layout = TableLayoutType.FIXED
654
+ }
655
+
656
+ // Whole-table width (distinct from per-cell width).
657
+ if (node.tableWidth) {
658
+ options.width = toTableCellWidth(node.tableWidth)
659
+ }
660
+
661
+ // Table-default borders. Per-cell borders still win.
662
+ if (node.tableBorders) {
663
+ options.borders = toBorders(node.tableBorders)
664
+ }
665
+
666
+ return new Table(options)
523
667
  }
524
668
 
525
669
  function irToTableRow(node) {
526
670
  const children = (node.children || [])
527
671
  .filter((child) => child.type === 'tableCell')
528
672
  .map(irToTableCell)
529
- return new TableRow({ children })
673
+
674
+ const options = { children }
675
+
676
+ // Header rows repeat at the top of each new page when the table breaks.
677
+ if (node.tableHeader) {
678
+ options.tableHeader = true
679
+ }
680
+
681
+ return new TableRow(options)
530
682
  }
531
683
 
532
684
  function irToTableCell(node) {
@@ -541,6 +693,24 @@ function irToTableCell(node) {
541
693
  if (node.borders) {
542
694
  options.borders = toBorders(node.borders)
543
695
  }
696
+ if (node.shading) {
697
+ options.shading = toShading(node.shading)
698
+ }
699
+ if (node.verticalAlign) {
700
+ options.verticalAlign = toVerticalAlign(node.verticalAlign)
701
+ }
702
+ // Merge spans. docx's `rowSpan` is the starting-cell shorthand —
703
+ // the library handles the continue cells internally, so we don't
704
+ // need to emit explicit vMerge=continue on subsequent rows from
705
+ // the foundation side.
706
+ if (node.columnSpan) {
707
+ const n = typeof node.columnSpan === 'string' ? parseInt(node.columnSpan, 10) : node.columnSpan
708
+ if (Number.isFinite(n) && n > 1) options.columnSpan = n
709
+ }
710
+ if (node.rowSpan) {
711
+ const n = typeof node.rowSpan === 'string' ? parseInt(node.rowSpan, 10) : node.rowSpan
712
+ if (Number.isFinite(n) && n > 1) options.rowSpan = n
713
+ }
544
714
 
545
715
  // Table cell children must be Paragraph or Table instances.
546
716
  const children = (node.children || []).flatMap((child) => {
@@ -669,6 +839,93 @@ function toBorders(borders) {
669
839
  return result
670
840
  }
671
841
 
842
+ // --- Cell shading ---
843
+
844
+ const SHADING_TYPES = {
845
+ clear: ShadingType.CLEAR,
846
+ nil: ShadingType.NIL,
847
+ solid: ShadingType.CLEAR, // alias — `solid` is the natural prop name
848
+ diagonalCross: ShadingType.DIAGONAL_CROSS,
849
+ diagonalStripe: ShadingType.DIAGONAL_STRIPE,
850
+ horizontalStripe: ShadingType.HORIZONTAL_STRIPE,
851
+ verticalStripe: ShadingType.VERTICAL_STRIPE,
852
+ }
853
+
854
+ /**
855
+ * Build a TableCell `shading` option from an IR shading object.
856
+ *
857
+ * Foundations almost always want a solid background: `<Td shading="4775b2">`.
858
+ * That JSX shorthand resolves to `{ fill: '4775b2' }` here, and we default
859
+ * `type: CLEAR` and `color: 'auto'` — the standard OOXML idiom for "plain
860
+ * fill, no overlay pattern". Without `type`, Word may render unpredictably
861
+ * across versions; without `color: 'auto'`, the library may emit
862
+ * `w:color=""` which Word repairs on open.
863
+ */
864
+ function toShading(shading) {
865
+ const fill = shading.fill || '000000'
866
+ const type = SHADING_TYPES[shading.type] ?? ShadingType.CLEAR
867
+ const color = shading.color || 'auto'
868
+ return { type, fill, color }
869
+ }
870
+
871
+ // --- Cell vertical alignment ---
872
+
873
+ const VERTICAL_ALIGNS = {
874
+ top: VerticalAlign.TOP,
875
+ center: VerticalAlign.CENTER,
876
+ middle: VerticalAlign.CENTER, // alias — natural-language CSS-ish
877
+ bottom: VerticalAlign.BOTTOM,
878
+ }
879
+
880
+ function toVerticalAlign(v) {
881
+ return VERTICAL_ALIGNS[v] ?? VerticalAlign.TOP
882
+ }
883
+
884
+ // --- Tab stops ---
885
+
886
+ const TAB_STOP_TYPES = {
887
+ left: TabStopType.LEFT,
888
+ right: TabStopType.RIGHT,
889
+ center: TabStopType.CENTER,
890
+ decimal: TabStopType.DECIMAL,
891
+ bar: TabStopType.BAR,
892
+ clear: TabStopType.CLEAR,
893
+ end: TabStopType.END,
894
+ num: TabStopType.NUM,
895
+ start: TabStopType.START,
896
+ }
897
+
898
+ const TAB_STOP_LEADERS = {
899
+ none: LeaderType.NONE,
900
+ dot: LeaderType.DOT,
901
+ hyphen: LeaderType.HYPHEN,
902
+ underscore: LeaderType.UNDERSCORE,
903
+ middleDot: LeaderType.MIDDLE_DOT,
904
+ }
905
+
906
+ /**
907
+ * Build a docx TabStopDefinition from an IR tab-stop entry.
908
+ *
909
+ * Foundations write `tabStops={[{position: cm(12), type: 'right', leader: 'dot'}]}`
910
+ * on a Paragraph. The builder JSON-stringifies the array; the IR
911
+ * transform parses it back here we coerce strings to docx enums.
912
+ *
913
+ * @returns {{type: string, position: number, leader?: string} | null}
914
+ */
915
+ function toTabStopDefinition(stop) {
916
+ if (!stop || typeof stop !== 'object') return null
917
+ const type = TAB_STOP_TYPES[stop.type] ?? TabStopType.LEFT
918
+ const positionRaw =
919
+ typeof stop.position === 'string' ? parseInt(stop.position, 10) : stop.position
920
+ if (!Number.isFinite(positionRaw)) return null
921
+ const def = { type, position: positionRaw }
922
+ if (stop.leader) {
923
+ const leader = TAB_STOP_LEADERS[stop.leader]
924
+ if (leader) def.leader = leader
925
+ }
926
+ return def
927
+ }
928
+
672
929
  // --- Positional tab ---
673
930
 
674
931
  const TAB_ALIGNMENTS = {
@@ -838,9 +1095,14 @@ async function irToImageParagraph(node, loadAsset) {
838
1095
  imageOptions.floating = node.floating
839
1096
  }
840
1097
 
841
- return new DocxParagraph({
1098
+ const paragraphOptions = {
842
1099
  children: [new ImageRun(imageOptions)],
843
- })
1100
+ }
1101
+ if (node.alignment) {
1102
+ paragraphOptions.alignment = toAlignment(node.alignment)
1103
+ }
1104
+
1105
+ return new DocxParagraph(paragraphOptions)
844
1106
  } catch (err) {
845
1107
  console.error(`Error creating image element:`, err)
846
1108
  return new DocxParagraph({})
@@ -379,6 +379,12 @@ function blockNodeToLatex(node) {
379
379
  case 'link':
380
380
  return inlineNodeToLatex(node)
381
381
 
382
+ case 'math':
383
+ // Bare math at block level (not wrapped in a paragraph). The
384
+ // inline emitter already produces the right `\[…\]` shape
385
+ // for display:true.
386
+ return inlineNodeToLatex(node)
387
+
382
388
  case 'paragraph':
383
389
  return inlineChildrenToLatex(node.children || []).trim()
384
390
 
@@ -596,6 +602,18 @@ function inlineNodeToLatex(node) {
596
602
  return `\\href{${href}}{${inner}}`
597
603
  }
598
604
 
605
+ if (node.type === 'math') {
606
+ // LaTeX is its own native math source — pass through verbatim.
607
+ // `$…$` for inline; `\[…\]` for display. The optional `\label{}`
608
+ // makes the equation referenceable from elsewhere via `\ref{}`
609
+ // / `\eqref{}`. The `amsmath` package (loaded by the standard
610
+ // preamble) provides `\eqref`; bare `\ref` works without it.
611
+ const display = node.display === 'true' || node.display === true
612
+ const src = node.latex || ''
613
+ const label = node.id ? `\\label{${node.id}}` : ''
614
+ return display ? `\\[${src}${label}\\]` : `$${src}$${label}`
615
+ }
616
+
599
617
  return ''
600
618
  }
601
619
 
@@ -366,6 +366,12 @@ function blockNodeToTypst(node) {
366
366
  case 'link':
367
367
  return inlineNodeToTypst(node)
368
368
 
369
+ case 'math':
370
+ // Bare math at block level (when an adapter is fed a single
371
+ // math element rather than a paragraph wrapping one). Reuse
372
+ // the inline emitter so the output stays consistent.
373
+ return inlineNodeToTypst(node)
374
+
369
375
  case 'paragraph':
370
376
  return inlineChildrenToTypst(node.children || []).trim()
371
377
 
@@ -496,8 +502,14 @@ function inlineNodeToTypst(node) {
496
502
  if (node.type === 'text') {
497
503
  let text = escapeTypstInline(node.content || '')
498
504
  if (node.code === 'true') text = '`' + (node.content || '') + '`'
499
- if (node.bold === 'true') text = `*${text}*`
500
- if (node.italics === 'true') text = `_${text}_`
505
+ // Use the function form `#strong[…]` / `#emph[…]` instead of the
506
+ // markup form `*…*` / `_…_`. Typst's markup-mode marks fail to
507
+ // close when the closing character butts directly against an
508
+ // alphanumeric (e.g. `_journal_4` keeps emphasis open through the
509
+ // rest of the document, producing "unclosed delimiter" errors).
510
+ // The function form has no boundary requirement.
511
+ if (node.bold === 'true') text = `#strong[${text}]`
512
+ if (node.italics === 'true') text = `#emph[${text}]`
501
513
  if (node.underline && (node.underline === 'true' || typeof node.underline === 'object')) {
502
514
  text = `#underline[${text}]`
503
515
  }
@@ -510,6 +522,26 @@ function inlineNodeToTypst(node) {
510
522
  return `#link(${quoteTypstString(href)})[${inner}]`
511
523
  }
512
524
 
525
+ if (node.type === 'math') {
526
+ // Math is delegated to the mitex typst package — foundations
527
+ // import mitex in their preamble. mitex emits typst math
528
+ // content (not arbitrary markup), so the call goes inside a
529
+ // typst math context: `$#mitex(\`…\`)$` for inline (no space
530
+ // around the dollars), `$ #mitex(\`…\`) $` for display (the
531
+ // spaces are typst's own inline-vs-display switch).
532
+ //
533
+ // The latex source goes inside backticks (a typst raw string).
534
+ // mitex parses LaTeX from the raw string and re-emits typst
535
+ // math. No JS-side LaTeX → typst translation needed.
536
+ const display = node.display === 'true' || node.display === true
537
+ const src = node.latex || ''
538
+ const labelSuffix = node.id ? ` <${node.id}>` : ''
539
+ if (display) {
540
+ return `\n$ #mitex(\`${src}\`) $${labelSuffix}\n`
541
+ }
542
+ return `$#mitex(\`${src}\`)$${labelSuffix}`
543
+ }
544
+
513
545
  return ''
514
546
  }
515
547
 
@@ -56,13 +56,13 @@ import { runCompile, getAdapterDescriptor } from './adapters/dispatch.js'
56
56
  * const blob = await compileTypst(compiled, { mode: 'sources', ... })
57
57
  */
58
58
  export function compileRegistrations(elements, format, options = {}) {
59
- const { basePath } = options
59
+ const { basePath, theme } = options
60
60
  const store = createStore()
61
61
 
62
62
  renderToStaticMarkup(
63
63
  createElement(
64
64
  DocumentProvider,
65
- { store, basePath },
65
+ { store, basePath, theme },
66
66
  elements,
67
67
  ),
68
68
  )
@@ -138,8 +138,8 @@ function warnIfEmptyRegistrations(store, format) {
138
138
  * triggerDownload(blob, 'book.zip')
139
139
  */
140
140
  export async function compileSubtree(elements, format, options = {}) {
141
- const { basePath, adapterOptions = {} } = options
142
- const compiled = compileRegistrations(elements, format, { basePath })
141
+ const { basePath, theme, adapterOptions = {} } = options
142
+ const compiled = compileRegistrations(elements, format, { basePath, theme })
143
143
  return runCompile(format, compiled, adapterOptions)
144
144
  }
145
145
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * BrandLogo — a brand image suitable for page headers, cover blocks,
3
+ * and watermarks.
4
+ *
5
+ * Stage 6.4 of kb/framework/plans/press-professional-docx.md.
6
+ *
7
+ * Emits a bare `<img data-type="image">` (no Paragraph wrapper) so
8
+ * the IR walker keeps the image at section level — Press's docx
9
+ * adapter creates the carrier paragraph itself, and inline-context
10
+ * images are silently dropped (Image.jsx's documented contract).
11
+ *
12
+ * Alignment flows through the IR's `data-alignment` channel: the
13
+ * adapter applies it to the carrier paragraph before emitting the
14
+ * ImageRun. Foundations get a right-aligned wordmark with one prop:
15
+ *
16
+ * <BrandLogo url={vendor.logo} width={cm(4)} align="right" />
17
+ *
18
+ * The legacy PHP-rendered Proximify invoice used a 5cm logo right-
19
+ * anchored in the page header. The default values match that visual.
20
+ */
21
+ import Image from './Image.jsx'
22
+
23
+ const DEFAULT_WIDTH = 180 // ~5 cm at the docx default DPI
24
+ const DEFAULT_HEIGHT_RATIO = 0.32 // typical horizontal-wordmark aspect
25
+
26
+ /**
27
+ * @param {Object} props
28
+ * @param {string} [props.url] - Image source. Resolved against
29
+ * DocumentProvider's basePath if site-absolute. When absent, the
30
+ * builder renders nothing.
31
+ * @param {string} [props.alt] - Accessibility text. Defaults to ''
32
+ * (decorative).
33
+ * @param {number} [props.width=180] - Width in docx transformation
34
+ * units. Use cm()/inch()/pt() for natural values.
35
+ * @param {number} [props.height] - Height. When omitted, defaults to
36
+ * width * 0.32 (typical horizontal-wordmark aspect).
37
+ * @param {'left'|'center'|'right'} [props.align='right'] - Alignment of
38
+ * the carrier paragraph the docx adapter wraps the ImageRun in.
39
+ */
40
+ export default function BrandLogo({
41
+ url,
42
+ alt = '',
43
+ width = DEFAULT_WIDTH,
44
+ height,
45
+ align = 'right',
46
+ ...rest
47
+ }) {
48
+ if (!url) return null
49
+ const h = height ?? Math.round(width * DEFAULT_HEIGHT_RATIO)
50
+ return (
51
+ <Image
52
+ data={{ url, alt }}
53
+ width={width}
54
+ height={h}
55
+ data-alignment={align}
56
+ {...rest}
57
+ />
58
+ )
59
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Math equation — non-text inline atom. Emits a marker span the IR
3
+ * walker turns into a `math` IR node. The docx adapter's v1 fallback
4
+ * emits the LaTeX source as plain text (better than the mathml soup
5
+ * it was leaking before this builder existed); a faithful OMML emitter
6
+ * is tracked in kb/framework/plans/math-print-adapters.md §"Future work".
7
+ *
8
+ * Inline and block math share the same builder, distinguished by the
9
+ * `display` flag. The optional `id` flows through for parity with the
10
+ * typst/latex builders even though docx v1 doesn't use it.
11
+ *
12
+ * Mirrors `Image` — same role (non-text inline atom), same attribute
13
+ * convention, same dispatch shape on the adapter side.
14
+ */
15
+ export default function Math({ latex, display = false, id, ...props }) {
16
+ const attrs = { 'data-type': 'math' }
17
+ attrs['data-latex'] = latex || ''
18
+ attrs['data-display'] = display ? 'true' : 'false'
19
+ if (id) attrs['data-id'] = id
20
+
21
+ return <span {...attrs} {...props} />
22
+ }