@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/press",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Press your React components into downloadable files — Word, Excel, PDF. Registration-based pattern with lazy-loaded format adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"./typst": "./src/typst/index.js",
|
|
10
10
|
"./latex": "./src/latex/index.js",
|
|
11
11
|
"./sections": "./src/sections/index.js",
|
|
12
|
+
"./format": "./src/format/index.jsx",
|
|
12
13
|
"./ir": "./src/ir/index.js",
|
|
13
14
|
"./vite-plugin-typst": "./src/vite-plugin-typst.js"
|
|
14
15
|
},
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
},
|
|
56
57
|
"scripts": {
|
|
57
58
|
"test": "vitest run",
|
|
58
|
-
"test:watch": "vitest"
|
|
59
|
+
"test:watch": "vitest",
|
|
60
|
+
"test:invoice": "vitest run tests/integration/invoice-fixtures/build-fixtures.test.jsx --reporter verbose"
|
|
59
61
|
}
|
|
60
62
|
}
|
package/src/DocumentProvider.jsx
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { createElement, useMemo } from 'react'
|
|
37
37
|
import { DocumentContext } from './DocumentContext.js'
|
|
38
38
|
import { BasePathContext } from './BasePathContext.js'
|
|
39
|
+
import { ThemeContext, DEFAULT_THEME } from './ThemeContext.js'
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* @typedef {Object} OutputEntry
|
|
@@ -76,6 +77,18 @@ export function createStore() {
|
|
|
76
77
|
// refs, but the walker filters them out.
|
|
77
78
|
const blockOrder = []
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Inner Map keys by `${format}@${role}` so a single block can
|
|
82
|
+
* register a body fragment + a header fragment + a footer
|
|
83
|
+
* fragment for the same format without colliding. Without this
|
|
84
|
+
* splitting, register(block, 'docx', body) followed by
|
|
85
|
+
* register(block, 'docx', header, { role: 'header' }) would
|
|
86
|
+
* overwrite the body — the second call's role tag is invisible
|
|
87
|
+
* to a plain `format` key.
|
|
88
|
+
*/
|
|
89
|
+
const keyFor = (format, options) =>
|
|
90
|
+
`${format}@${(options && options.role) || 'body'}`
|
|
91
|
+
|
|
79
92
|
return {
|
|
80
93
|
register(block, format, fragment, options = {}) {
|
|
81
94
|
let formatMap = outputs.get(block)
|
|
@@ -84,17 +97,19 @@ export function createStore() {
|
|
|
84
97
|
outputs.set(block, formatMap)
|
|
85
98
|
blockOrder.push(block)
|
|
86
99
|
}
|
|
87
|
-
formatMap.set(format, { fragment, options })
|
|
100
|
+
formatMap.set(keyFor(format, options), { fragment, options })
|
|
88
101
|
},
|
|
89
102
|
|
|
90
103
|
getOutputs(format) {
|
|
91
104
|
const result = []
|
|
105
|
+
const prefix = `${format}@`
|
|
92
106
|
for (const block of blockOrder) {
|
|
93
107
|
const formatMap = outputs.get(block)
|
|
94
108
|
if (!formatMap) continue
|
|
95
|
-
const entry
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
for (const [key, entry] of formatMap) {
|
|
110
|
+
if (key.startsWith(prefix)) {
|
|
111
|
+
result.push({ block, ...entry })
|
|
112
|
+
}
|
|
98
113
|
}
|
|
99
114
|
}
|
|
100
115
|
return result
|
|
@@ -111,7 +126,29 @@ export function createStore() {
|
|
|
111
126
|
}
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Merge a partial theme into the defaults. Foundations only need to
|
|
131
|
+
* override the keys they care about; the rest fall back to DEFAULT_THEME.
|
|
132
|
+
*/
|
|
133
|
+
function mergeTheme(theme) {
|
|
134
|
+
if (!theme) return DEFAULT_THEME
|
|
135
|
+
return {
|
|
136
|
+
colors: { ...DEFAULT_THEME.colors, ...(theme.colors || {}) },
|
|
137
|
+
fonts: { ...DEFAULT_THEME.fonts, ...(theme.fonts || {}) },
|
|
138
|
+
...Object.fromEntries(
|
|
139
|
+
Object.entries(theme).filter(
|
|
140
|
+
([k]) => k !== 'colors' && k !== 'fonts',
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default function DocumentProvider({
|
|
147
|
+
children,
|
|
148
|
+
basePath = '',
|
|
149
|
+
theme,
|
|
150
|
+
store: externalStore,
|
|
151
|
+
}) {
|
|
115
152
|
// Prefer an externally-provided store when the caller wants to own
|
|
116
153
|
// the aggregation (for off-screen / whole-site compiles). Otherwise
|
|
117
154
|
// build one internally — the common case for live page rendering.
|
|
@@ -120,33 +157,34 @@ export default function DocumentProvider({ children, basePath = '', store: exter
|
|
|
120
157
|
[externalStore],
|
|
121
158
|
)
|
|
122
159
|
|
|
160
|
+
const resolvedBasePath = basePath || ''
|
|
161
|
+
const resolvedTheme = useMemo(() => mergeTheme(theme), [theme])
|
|
162
|
+
|
|
123
163
|
// Expose the provider's full context stack as a closure so the
|
|
124
164
|
// compile pipeline can re-wrap registered fragments with the same
|
|
125
165
|
// contexts they rendered under — necessary because registrations
|
|
126
166
|
// capture JSX element trees, not rendered output, and a later
|
|
127
|
-
// renderToStaticMarkup(fragment) starts outside any React tree
|
|
128
|
-
// (builders would otherwise fall back to context defaults and
|
|
129
|
-
// emit, for example, un-prefixed URLs).
|
|
167
|
+
// renderToStaticMarkup(fragment) starts outside any React tree.
|
|
130
168
|
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// object identity is memoised.
|
|
134
|
-
//
|
|
135
|
-
// When adding a new cross-cutting context that builders consume
|
|
136
|
-
// (theme, locale, author, …), wrap it here — the compile pipeline
|
|
137
|
-
// does not need to change.
|
|
138
|
-
const resolvedBasePath = basePath || ''
|
|
169
|
+
// When adding a new cross-cutting context that builders consume,
|
|
170
|
+
// wrap it here — the compile pipeline does not need to change.
|
|
139
171
|
store.wrapWithProviders = (children) =>
|
|
140
172
|
createElement(
|
|
141
173
|
BasePathContext.Provider,
|
|
142
174
|
{ value: resolvedBasePath },
|
|
143
|
-
|
|
175
|
+
createElement(
|
|
176
|
+
ThemeContext.Provider,
|
|
177
|
+
{ value: resolvedTheme },
|
|
178
|
+
children,
|
|
179
|
+
),
|
|
144
180
|
)
|
|
145
181
|
|
|
146
182
|
return (
|
|
147
183
|
<DocumentContext.Provider value={store}>
|
|
148
184
|
<BasePathContext.Provider value={resolvedBasePath}>
|
|
149
|
-
{
|
|
185
|
+
<ThemeContext.Provider value={resolvedTheme}>
|
|
186
|
+
{children}
|
|
187
|
+
</ThemeContext.Provider>
|
|
150
188
|
</BasePathContext.Provider>
|
|
151
189
|
</DocumentContext.Provider>
|
|
152
190
|
)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document theme — colors, fonts, and other cross-cutting brand tokens
|
|
3
|
+
* that builders should consult when foundations don't pass explicit
|
|
4
|
+
* literal values.
|
|
5
|
+
*
|
|
6
|
+
* Stage 5 of kb/framework/plans/press-professional-docx.md.
|
|
7
|
+
*
|
|
8
|
+
* The shape is intentionally small and forgiving: a foundation passes
|
|
9
|
+
* whatever subset of keys it cares about, and builders fall back to
|
|
10
|
+
* sensible defaults when a key is absent. The DEFAULT_THEME below
|
|
11
|
+
* documents the full surface; foundations may override any subset.
|
|
12
|
+
*
|
|
13
|
+
* Resolution timing: builders consume the theme during React render
|
|
14
|
+
* and emit literal hex/font values into data-* attributes. The IR
|
|
15
|
+
* walker and the docx adapter never see theme keys — by the time IR
|
|
16
|
+
* exists, the substitution has already happened. This keeps the
|
|
17
|
+
* adapter ignorant of theming and means foundations can pre-render
|
|
18
|
+
* outside a provider for testing without crashing on missing keys.
|
|
19
|
+
*
|
|
20
|
+
* `wrapWithProviders` re-applies the theme during the compile pass
|
|
21
|
+
* (renderToStaticMarkup outside the original React tree), so the
|
|
22
|
+
* fragment registered live and the fragment compiled later resolve
|
|
23
|
+
* to the same values.
|
|
24
|
+
*/
|
|
25
|
+
import { createContext, useContext } from 'react'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The full shape, with documented defaults. Foundations may override
|
|
29
|
+
* any subset.
|
|
30
|
+
*
|
|
31
|
+
* Color values are hex strings without the leading `#`. Foundations
|
|
32
|
+
* that pass a value with `#` get it stripped on resolve so authors
|
|
33
|
+
* don't have to remember which form.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_THEME = {
|
|
36
|
+
colors: {
|
|
37
|
+
// Primary brand accent — used for titles, header rows, and
|
|
38
|
+
// emphasis. Foundations should override this.
|
|
39
|
+
accent: '0B5394',
|
|
40
|
+
// Body text color.
|
|
41
|
+
body: '3B3B3B',
|
|
42
|
+
// Secondary / muted text (helper labels, footers).
|
|
43
|
+
muted: '757575',
|
|
44
|
+
// Soft border / grid color (table grid, dividers).
|
|
45
|
+
softBorder: 'BFD3ED',
|
|
46
|
+
// Stage 6+: surface, surfaceAlt for tinted backgrounds.
|
|
47
|
+
surface: 'FFFFFF',
|
|
48
|
+
surfaceAlt: 'F5F7FB',
|
|
49
|
+
},
|
|
50
|
+
fonts: {
|
|
51
|
+
// Default body font.
|
|
52
|
+
body: 'Calibri',
|
|
53
|
+
// Headings / titles. Match body to keep things simple.
|
|
54
|
+
heading: 'Calibri',
|
|
55
|
+
// Monospace (code, fixed-width data). Stage 6+.
|
|
56
|
+
mono: 'Consolas',
|
|
57
|
+
},
|
|
58
|
+
/**
|
|
59
|
+
* Default locale for date / currency formatting. Foundations
|
|
60
|
+
* override per-document via `website.config.business_docs.theme.locale`.
|
|
61
|
+
* Builders that read the theme (`<DateText>`, `<DateRangeText>`,
|
|
62
|
+
* `<Currency>`) fall back to this value when no explicit `locale`
|
|
63
|
+
* prop is given. Format strings follow the BCP-47 convention.
|
|
64
|
+
*/
|
|
65
|
+
locale: 'en-CA',
|
|
66
|
+
/**
|
|
67
|
+
* Default ISO 4217 currency code for `<Currency>` when no explicit
|
|
68
|
+
* `code` is set on the builder.
|
|
69
|
+
*/
|
|
70
|
+
currency: 'CAD',
|
|
71
|
+
/**
|
|
72
|
+
* Typography roles. Each entry is a small `{ font, size, bold,
|
|
73
|
+
* italics, color, smallCaps, allCaps, strike, paragraph }` shape;
|
|
74
|
+
* `font` and `color` may be theme keys ('body', 'heading', 'accent',
|
|
75
|
+
* …) which resolve at compile time. `size` is in half-points
|
|
76
|
+
* (use the convertPointsToHalfPoints helper / `pt(n)` wrapper).
|
|
77
|
+
*
|
|
78
|
+
* Press synthesises OOXML paragraph styles + character styles from
|
|
79
|
+
* this registry on docx compile. Builders consume roles via
|
|
80
|
+
* `<Paragraph role="Title">` / `<TextRun role="Label">`; the docx
|
|
81
|
+
* adapter emits style references (<w:pStyle>/<w:rStyle>) instead
|
|
82
|
+
* of inline run properties, so users can edit fonts/colors/sizes
|
|
83
|
+
* from Word's Styles pane without find-and-replace formatting.
|
|
84
|
+
*
|
|
85
|
+
* Roles split into two natural buckets:
|
|
86
|
+
* - block-level (paragraph): set both paragraph and run properties,
|
|
87
|
+
* applied via `<Paragraph role="…">`. Use for whole-paragraph
|
|
88
|
+
* constructs like Title, Heading1-3, Body, Display.
|
|
89
|
+
* - inline (character): set run properties only, applied via
|
|
90
|
+
* `<TextRun role="…">`. Use for inline emphasis like Label,
|
|
91
|
+
* Caption, BodyStrong.
|
|
92
|
+
*
|
|
93
|
+
* Foundations override individual entries by passing
|
|
94
|
+
* `theme.typography.<RoleName>` partial — anything not specified
|
|
95
|
+
* inherits the default.
|
|
96
|
+
*/
|
|
97
|
+
typography: {
|
|
98
|
+
// ---- Block-level roles (paragraph) -----------------------------
|
|
99
|
+
Title: {
|
|
100
|
+
font: 'heading',
|
|
101
|
+
size: 56,
|
|
102
|
+
bold: true,
|
|
103
|
+
color: 'accent',
|
|
104
|
+
paragraph: { spacing: { after: 240 } },
|
|
105
|
+
},
|
|
106
|
+
Heading1: {
|
|
107
|
+
font: 'heading',
|
|
108
|
+
size: 32,
|
|
109
|
+
bold: true,
|
|
110
|
+
color: 'body',
|
|
111
|
+
paragraph: { spacing: { before: 240, after: 120 } },
|
|
112
|
+
},
|
|
113
|
+
Heading2: {
|
|
114
|
+
font: 'heading',
|
|
115
|
+
size: 26,
|
|
116
|
+
bold: true,
|
|
117
|
+
color: 'body',
|
|
118
|
+
paragraph: { spacing: { before: 200, after: 100 } },
|
|
119
|
+
},
|
|
120
|
+
Heading3: {
|
|
121
|
+
font: 'heading',
|
|
122
|
+
size: 22,
|
|
123
|
+
bold: true,
|
|
124
|
+
color: 'body',
|
|
125
|
+
paragraph: { spacing: { before: 160, after: 80 } },
|
|
126
|
+
},
|
|
127
|
+
Body: {
|
|
128
|
+
font: 'body',
|
|
129
|
+
size: 22,
|
|
130
|
+
color: 'body',
|
|
131
|
+
paragraph: { spacing: { line: 276 } }, // 1.15 line height
|
|
132
|
+
},
|
|
133
|
+
Display: {
|
|
134
|
+
font: 'body',
|
|
135
|
+
size: 28,
|
|
136
|
+
bold: true,
|
|
137
|
+
color: 'body',
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// ---- Inline roles (character) ----------------------------------
|
|
141
|
+
BodyStrong: { font: 'body', size: 22, bold: true, color: 'body' },
|
|
142
|
+
Label: {
|
|
143
|
+
font: 'body',
|
|
144
|
+
size: 18,
|
|
145
|
+
bold: true,
|
|
146
|
+
color: 'muted',
|
|
147
|
+
allCaps: true,
|
|
148
|
+
},
|
|
149
|
+
Caption: { font: 'body', size: 18, color: 'muted' },
|
|
150
|
+
TableHeader: {
|
|
151
|
+
font: 'heading',
|
|
152
|
+
size: 20,
|
|
153
|
+
bold: true,
|
|
154
|
+
color: 'surface',
|
|
155
|
+
},
|
|
156
|
+
TotalLine: {
|
|
157
|
+
font: 'heading',
|
|
158
|
+
size: 26,
|
|
159
|
+
bold: true,
|
|
160
|
+
color: 'surface',
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
/**
|
|
164
|
+
* Roles whose declaration should land in the OOXML paragraphStyles
|
|
165
|
+
* bucket vs the characterStyles bucket. Block roles cover both
|
|
166
|
+
* paragraph and run properties; inline roles cover only run
|
|
167
|
+
* properties. Foundations adding new roles should classify them
|
|
168
|
+
* here; unclassified roles default to character-style (run-only).
|
|
169
|
+
*/
|
|
170
|
+
typographyKinds: {
|
|
171
|
+
Title: 'paragraph',
|
|
172
|
+
Heading1: 'paragraph',
|
|
173
|
+
Heading2: 'paragraph',
|
|
174
|
+
Heading3: 'paragraph',
|
|
175
|
+
Body: 'paragraph',
|
|
176
|
+
Display: 'paragraph',
|
|
177
|
+
BodyStrong: 'character',
|
|
178
|
+
Label: 'character',
|
|
179
|
+
Caption: 'character',
|
|
180
|
+
TableHeader: 'character',
|
|
181
|
+
TotalLine: 'character',
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const ThemeContext = createContext(DEFAULT_THEME)
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Look up the active theme. Builders that need theme values call this
|
|
189
|
+
* at render time. Outside a DocumentProvider, returns DEFAULT_THEME.
|
|
190
|
+
*/
|
|
191
|
+
export function useDocumentTheme() {
|
|
192
|
+
return useContext(ThemeContext) || DEFAULT_THEME
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve a color prop value against the theme.
|
|
197
|
+
*
|
|
198
|
+
* Accepts:
|
|
199
|
+
* - A theme key matching `theme.colors.<key>` (e.g. 'accent', 'body',
|
|
200
|
+
* 'muted', 'softBorder') — returned as the resolved hex.
|
|
201
|
+
* - A hex literal with or without leading '#' — returned without '#'.
|
|
202
|
+
* - Any other string — returned verbatim (the docx layer falls back
|
|
203
|
+
* to library defaults if it doesn't recognize the value).
|
|
204
|
+
*
|
|
205
|
+
* Returns undefined for nullish input so builders can chain
|
|
206
|
+
* `if (resolved) defaults['data-color'] = resolved` cleanly.
|
|
207
|
+
*/
|
|
208
|
+
export function resolveThemeColor(value, theme = DEFAULT_THEME) {
|
|
209
|
+
if (!value) return undefined
|
|
210
|
+
if (typeof value !== 'string') return value
|
|
211
|
+
const colors = theme && theme.colors ? theme.colors : DEFAULT_THEME.colors
|
|
212
|
+
if (Object.prototype.hasOwnProperty.call(colors, value)) {
|
|
213
|
+
return stripHash(colors[value])
|
|
214
|
+
}
|
|
215
|
+
return stripHash(value)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolve a font-family prop against the theme.
|
|
220
|
+
*
|
|
221
|
+
* Accepts:
|
|
222
|
+
* - A theme key matching `theme.fonts.<key>` (e.g. 'body', 'heading').
|
|
223
|
+
* - A literal family name — returned verbatim.
|
|
224
|
+
*/
|
|
225
|
+
export function resolveThemeFont(value, theme = DEFAULT_THEME) {
|
|
226
|
+
if (!value) return undefined
|
|
227
|
+
if (typeof value !== 'string') return value
|
|
228
|
+
const fonts = theme && theme.fonts ? theme.fonts : DEFAULT_THEME.fonts
|
|
229
|
+
if (Object.prototype.hasOwnProperty.call(fonts, value)) {
|
|
230
|
+
return fonts[value]
|
|
231
|
+
}
|
|
232
|
+
return value
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function stripHash(hex) {
|
|
236
|
+
return typeof hex === 'string' && hex.startsWith('#') ? hex.slice(1) : hex
|
|
237
|
+
}
|