@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.
- package/package.json +4 -2
- package/src/DocumentProvider.jsx +56 -18
- package/src/ThemeContext.js +237 -0
- package/src/adapters/docx.js +273 -11
- package/src/adapters/latex.js +18 -0
- package/src/adapters/typst.js +34 -2
- package/src/compileRegistrations.js +4 -4
- package/src/docx/BrandLogo.jsx +59 -0
- package/src/docx/Math.jsx +22 -0
- package/src/docx/PageChrome.jsx +109 -0
- package/src/docx/Paragraph.jsx +93 -17
- package/src/docx/Table.jsx +156 -21
- package/src/docx/TextRun.jsx +83 -4
- package/src/docx/index.js +14 -1
- package/src/docx/parseStyledString.js +59 -6
- package/src/docx/units.js +21 -0
- package/src/format/index.jsx +171 -0
- package/src/index.js +6 -0
- package/src/ir/attributes.js +135 -0
- package/src/styles/buildStylePack.js +190 -0
- package/src/typst/Math.jsx +23 -0
- package/src/typst/Paragraph.jsx +26 -12
- package/src/typst/Sequence.jsx +15 -0
- package/src/typst/index.js +1 -0
|
@@ -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
|
|
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
|
|
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. `&` last so a literal
|
|
42
|
+
// `&quot;` round-trips correctly.
|
|
43
|
+
function decodeAttr(s) {
|
|
44
|
+
return String(s)
|
|
45
|
+
.replace(/"/g, '"')
|
|
46
|
+
.replace(/</g, '<')
|
|
47
|
+
.replace(/>/g, '>')
|
|
48
|
+
.replace(/&/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
|
|
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
package/src/ir/attributes.js
CHANGED
|
@@ -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
|
+
}
|