@uniweb/press 0.4.2 → 0.4.3
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 +2 -1
- package/src/adapters/dispatch.js +6 -0
- package/src/adapters/latex.js +743 -0
- package/src/latex/index.js +54 -0
- package/src/latex/raw.js +41 -0
- package/src/typst/Heading.jsx +4 -3
- package/src/typst/Image.jsx +7 -2
- package/src/typst/Sequence.jsx +7 -1
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/press",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
8
|
"./docx": "./src/docx/index.js",
|
|
9
9
|
"./typst": "./src/typst/index.js",
|
|
10
|
+
"./latex": "./src/latex/index.js",
|
|
10
11
|
"./sections": "./src/sections/index.js",
|
|
11
12
|
"./ir": "./src/ir/index.js",
|
|
12
13
|
"./vite-plugin-typst": "./src/vite-plugin-typst.js"
|
package/src/adapters/dispatch.js
CHANGED
|
@@ -40,6 +40,11 @@ const ADAPTERS = {
|
|
|
40
40
|
docx: { load: () => import('./docx.js'), consumes: 'docx', ir: true },
|
|
41
41
|
xlsx: { load: () => import('./xlsx.js'), consumes: 'xlsx', ir: false },
|
|
42
42
|
typst: { load: () => import('./typst.js'), consumes: 'typst', ir: true },
|
|
43
|
+
// LaTeX consumes its own input key. Foundations targeting both Typst
|
|
44
|
+
// and LaTeX register under each format separately — same JSX, two
|
|
45
|
+
// useDocumentOutput calls — so the adapters can diverge as the
|
|
46
|
+
// formats need without forcing a shared input shape.
|
|
47
|
+
latex: { load: () => import('./latex.js'), consumes: 'latex', ir: true },
|
|
43
48
|
// Paged.js consumes 'html' — an input shape shared with EPUB below.
|
|
44
49
|
// Foundations register once under 'html' and both adapters read it.
|
|
45
50
|
pagedjs: { load: () => import('./pagedjs.js'), consumes: 'html', ir: false },
|
|
@@ -79,6 +84,7 @@ export async function runCompile(format, compiledInput, documentOptions = {}) {
|
|
|
79
84
|
adapter.compileDocx ||
|
|
80
85
|
adapter.compileXlsx ||
|
|
81
86
|
adapter.compileTypst ||
|
|
87
|
+
adapter.compileLatex ||
|
|
82
88
|
adapter.compilePagedjs ||
|
|
83
89
|
adapter.compileEpub ||
|
|
84
90
|
adapter.compilePdf
|
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal LaTeX format adapter.
|
|
3
|
+
*
|
|
4
|
+
* Walks the compile-pipeline output (src/ir/compile.js) — { sections,
|
|
5
|
+
* header, footer, metadata } of IR node arrays — and produces a LaTeX
|
|
6
|
+
* source bundle. Phase 1 ships `sources` mode: return a ZIP Blob of the
|
|
7
|
+
* bundle; the user runs `latexmk -pdf main.tex` (or `pdflatex` /
|
|
8
|
+
* `xelatex` / `lualatex`) locally.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2 will add `server` mode (POST the bundle to an endpoint that
|
|
11
|
+
* runs a TeX engine and returns a PDF). LaTeX has no realistic WASM
|
|
12
|
+
* path today (TeX-in-browser is heavier and less mature than
|
|
13
|
+
* Typst-in-browser), so the frontend-first story is carried by
|
|
14
|
+
* `sources` mode rather than by in-browser compilation.
|
|
15
|
+
*
|
|
16
|
+
* Bundle shape (sources mode):
|
|
17
|
+
*
|
|
18
|
+
* main.tex entry: \documentclass + preamble + \begin{document}
|
|
19
|
+
* + \input{content} + \end{document}
|
|
20
|
+
* meta.tex \def\unimeta@title{…} \def\unimeta@author{…} (one
|
|
21
|
+
* macro per metadata field, namespaced to avoid
|
|
22
|
+
* collisions with template macros)
|
|
23
|
+
* content.tex assembled body — one "% --- section N ---" block
|
|
24
|
+
* per registered section
|
|
25
|
+
* preamble.tex foundation-supplied \usepackage stack + custom
|
|
26
|
+
* command definitions (chapter-opener, asterism, …)
|
|
27
|
+
* template.tex foundation-supplied \documentclass + page geometry
|
|
28
|
+
* + show-style declarations (fontspec, geometry,
|
|
29
|
+
* hyperref configuration, etc.)
|
|
30
|
+
* refs.bib (when foundation supplies bibtex) — biblatex
|
|
31
|
+
* bibliography records
|
|
32
|
+
* assets/ (when foundation supplies bytes) — images, the
|
|
33
|
+
* foundation-shipped .cls file, etc., placed at
|
|
34
|
+
* paths the foundation specifies via
|
|
35
|
+
* adapterOptions.assets
|
|
36
|
+
*
|
|
37
|
+
* This module is internal. It is NOT listed in package.json's exports
|
|
38
|
+
* field; consumers reach it only via the dynamic import inside
|
|
39
|
+
* useDocumentCompile, which keeps jszip and any future TeX runtime out
|
|
40
|
+
* of the main bundle.
|
|
41
|
+
*
|
|
42
|
+
* Mirrors src/adapters/typst.js end-to-end. The IR walker structure is
|
|
43
|
+
* the same; only the emit functions differ (LaTeX commands instead of
|
|
44
|
+
* Typst markup).
|
|
45
|
+
*
|
|
46
|
+
* Entry point: compileLatex(compiledInput, options) → Promise<Blob>
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import JSZip from 'jszip'
|
|
50
|
+
import { RAW_BEGIN, RAW_END, markRawLatex } from '../latex/raw.js'
|
|
51
|
+
|
|
52
|
+
// Re-export so consumers reaching the adapter via the dynamic-import
|
|
53
|
+
// path also see markRawLatex. (Static foundations should import it
|
|
54
|
+
// from `@uniweb/press/latex` — the builders barrel — to avoid
|
|
55
|
+
// triggering the adapter's lazy load. This re-export is for
|
|
56
|
+
// completeness and tests.)
|
|
57
|
+
export { markRawLatex }
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Public API
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compile walker output into a LaTeX bundle Blob.
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} input - Output of compileOutputs(store, 'latex').
|
|
67
|
+
* @param {Object[][]} input.sections
|
|
68
|
+
* @param {Object[]|null} [input.header]
|
|
69
|
+
* @param {Object[]|null} [input.footer]
|
|
70
|
+
* @param {Object|null} [input.metadata] - Plain data object from the
|
|
71
|
+
* `role: 'metadata'` registration (title, author, year, …).
|
|
72
|
+
* @param {Object} [options]
|
|
73
|
+
* @param {'sources'|'server'} [options.mode='sources'] - Compile mode.
|
|
74
|
+
* @param {string} [options.preamble] - Foundation-supplied preamble.tex
|
|
75
|
+
* content (\usepackage stack, custom commands).
|
|
76
|
+
* @param {string} [options.template] - Foundation-supplied template.tex
|
|
77
|
+
* content (\documentclass, geometry, fonts, hyperref).
|
|
78
|
+
* @param {Object} [options.meta] - Additional metadata to merge over the
|
|
79
|
+
* `metadata` registration (options.meta wins).
|
|
80
|
+
* @param {Object} [options.assets] - Extra bundle files keyed by path
|
|
81
|
+
* (path → Uint8Array | Blob | string). Foundation hands the adapter
|
|
82
|
+
* already-resolved bytes; the adapter just packs them.
|
|
83
|
+
* @param {string} [options.endpoint] - Server-mode endpoint URL.
|
|
84
|
+
* @returns {Promise<Blob>}
|
|
85
|
+
*/
|
|
86
|
+
export async function compileLatex(input, options = {}) {
|
|
87
|
+
const { mode = 'sources', ...rest } = options
|
|
88
|
+
|
|
89
|
+
const bundle = buildBundle(input, rest)
|
|
90
|
+
|
|
91
|
+
if (mode === 'sources') {
|
|
92
|
+
return zipBundle(bundle)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (mode === 'server') {
|
|
96
|
+
return compileServerSide(bundle, rest)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(
|
|
100
|
+
`LaTeX adapter: unknown mode "${mode}". ` +
|
|
101
|
+
`Valid modes: 'sources' (ZIP of .tex files), 'server' (POST bundle to endpoint, receive PDF).`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Server mode: POST the bundle to an endpoint that runs `latexmk` (or
|
|
107
|
+
* `pdflatex` etc.) and returns a PDF. Mirrors the typst adapter's
|
|
108
|
+
* server-mode wire protocol — multipart/form-data with one field per
|
|
109
|
+
* bundle file, using the filename as the field name.
|
|
110
|
+
*
|
|
111
|
+
* No reference dev plugin ships today; foundations or hosts that want
|
|
112
|
+
* server mode supply their own endpoint. See
|
|
113
|
+
* docs/architecture/deployment.md for the wire-protocol contract.
|
|
114
|
+
*/
|
|
115
|
+
async function compileServerSide(bundle, options = {}) {
|
|
116
|
+
const endpoint = options.endpoint || '/__press/latex/compile'
|
|
117
|
+
|
|
118
|
+
const form = new FormData()
|
|
119
|
+
for (const [name, contents] of Object.entries(bundle)) {
|
|
120
|
+
if (contents instanceof Blob) {
|
|
121
|
+
form.append(name, contents, name)
|
|
122
|
+
} else if (
|
|
123
|
+
contents instanceof Uint8Array ||
|
|
124
|
+
contents instanceof ArrayBuffer
|
|
125
|
+
) {
|
|
126
|
+
form.append(
|
|
127
|
+
name,
|
|
128
|
+
new Blob([contents], { type: 'application/octet-stream' }),
|
|
129
|
+
name,
|
|
130
|
+
)
|
|
131
|
+
} else {
|
|
132
|
+
form.append(name, new Blob([contents], { type: 'text/plain' }), name)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let res
|
|
137
|
+
try {
|
|
138
|
+
res = await fetch(endpoint, { method: 'POST', body: form })
|
|
139
|
+
} catch (err) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`LaTeX adapter (server mode): request to ${endpoint} failed. ` +
|
|
142
|
+
`Is the dev server (or your compile endpoint) running? ` +
|
|
143
|
+
`Original error: ${err.message || err}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const text = await res.text().catch(() => '(no body)')
|
|
149
|
+
throw new Error(
|
|
150
|
+
`LaTeX adapter (server mode): ${endpoint} returned ${res.status} ${res.statusText}.\n${text}`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const blob = await res.blob()
|
|
155
|
+
return blob.type === 'application/pdf'
|
|
156
|
+
? blob
|
|
157
|
+
: new Blob([await blob.arrayBuffer()], { type: 'application/pdf' })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the in-memory LaTeX bundle. Exported for testing — callers that
|
|
162
|
+
* want to inspect the emitted source before zipping can use this directly.
|
|
163
|
+
*
|
|
164
|
+
* @returns {{ [filename: string]: string | Uint8Array | Blob }}
|
|
165
|
+
*/
|
|
166
|
+
export function buildBundle(input, options = {}) {
|
|
167
|
+
const {
|
|
168
|
+
sections = [],
|
|
169
|
+
header = null, // TODO Phase 2: wire into template.tex header hook
|
|
170
|
+
footer = null, // TODO Phase 2: wire into template.tex footer hook
|
|
171
|
+
metadata = null,
|
|
172
|
+
} = input
|
|
173
|
+
|
|
174
|
+
const {
|
|
175
|
+
preamble = DEFAULT_PREAMBLE,
|
|
176
|
+
template = DEFAULT_TEMPLATE,
|
|
177
|
+
meta: metaOverride = {},
|
|
178
|
+
assets = {}, // extra bundle files (path → Uint8Array / Blob / string)
|
|
179
|
+
} = options
|
|
180
|
+
|
|
181
|
+
// Merge: metadata role (document-level) + options.meta (call-site override)
|
|
182
|
+
const resolvedMeta = { ...(metadata || {}), ...metaOverride }
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
'main.tex': emitMain(),
|
|
186
|
+
'meta.tex': emitMeta(resolvedMeta),
|
|
187
|
+
'content.tex': emitContent(sections),
|
|
188
|
+
'preamble.tex': preamble,
|
|
189
|
+
'template.tex': template,
|
|
190
|
+
...assets,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// Bundle serialisation
|
|
196
|
+
// ============================================================================
|
|
197
|
+
|
|
198
|
+
async function zipBundle(bundle) {
|
|
199
|
+
const zip = new JSZip()
|
|
200
|
+
for (const [path, content] of Object.entries(bundle)) {
|
|
201
|
+
zip.file(path, content)
|
|
202
|
+
}
|
|
203
|
+
return zip.generateAsync({ type: 'blob', mimeType: 'application/zip' })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// main.tex — stable entry
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
function emitMain() {
|
|
211
|
+
return [
|
|
212
|
+
'% Auto-generated by @uniweb/press/latex.',
|
|
213
|
+
'% Do not edit by hand — re-run the document build to regenerate.',
|
|
214
|
+
'%',
|
|
215
|
+
'% template.tex declares \\documentclass and the document-shape',
|
|
216
|
+
'% packages (geometry, fontspec, hyperref, biblatex, etc.).',
|
|
217
|
+
'% preamble.tex declares custom commands and macros the body uses',
|
|
218
|
+
'% (e.g. \\chapteropener, \\sectionbreak).',
|
|
219
|
+
'% meta.tex defines \\unimeta@title / \\unimeta@author / … macros',
|
|
220
|
+
'% the template can read.',
|
|
221
|
+
'',
|
|
222
|
+
'\\input{template}',
|
|
223
|
+
'\\input{preamble}',
|
|
224
|
+
'\\input{meta}',
|
|
225
|
+
'',
|
|
226
|
+
'\\begin{document}',
|
|
227
|
+
'',
|
|
228
|
+
'\\input{content}',
|
|
229
|
+
'',
|
|
230
|
+
'\\end{document}',
|
|
231
|
+
'',
|
|
232
|
+
].join('\n')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// meta.tex — document metadata as namespaced LaTeX macros
|
|
237
|
+
// ============================================================================
|
|
238
|
+
|
|
239
|
+
function emitMeta(meta) {
|
|
240
|
+
const lines = [
|
|
241
|
+
'% Document metadata. Generated from the role:"metadata" registration',
|
|
242
|
+
'% in the foundation\'s layout, merged with any options.meta passed',
|
|
243
|
+
'% to compile(\'latex\', options).',
|
|
244
|
+
'%',
|
|
245
|
+
'% Each field is exposed as \\unimeta@<key>; templates read these',
|
|
246
|
+
'% via \\title{\\unimeta@title}, \\author{\\unimeta@author}, etc.',
|
|
247
|
+
'% Camel-case JS keys map to lowercase LaTeX macro names so the',
|
|
248
|
+
'% TeX engine accepts them as control sequences (no @ in the body).',
|
|
249
|
+
'',
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
// Known keys in stable emission order, for readability.
|
|
253
|
+
const known = [
|
|
254
|
+
'title',
|
|
255
|
+
'subtitle',
|
|
256
|
+
'author',
|
|
257
|
+
'date',
|
|
258
|
+
'language',
|
|
259
|
+
'isbn',
|
|
260
|
+
'identifier',
|
|
261
|
+
'rights',
|
|
262
|
+
'publisher',
|
|
263
|
+
'subject',
|
|
264
|
+
'description',
|
|
265
|
+
'coverImage',
|
|
266
|
+
'hook',
|
|
267
|
+
'blurb',
|
|
268
|
+
'tocDepth',
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
const entries = []
|
|
272
|
+
const seen = new Set()
|
|
273
|
+
for (const key of known) {
|
|
274
|
+
if (meta?.[key] != null) {
|
|
275
|
+
entries.push([key, meta[key]])
|
|
276
|
+
seen.add(key)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
for (const [key, value] of Object.entries(meta || {})) {
|
|
280
|
+
if (seen.has(key)) continue
|
|
281
|
+
entries.push([key, value])
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Templates can rely on tocDepth always being defined.
|
|
285
|
+
if (!seen.has('tocDepth') && meta?.toc_depth == null) {
|
|
286
|
+
entries.push(['tocDepth', 2])
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
lines.push('\\makeatletter')
|
|
290
|
+
for (const [key, value] of entries) {
|
|
291
|
+
lines.push(`\\def\\unimeta@${toLatexIdent(key)}{${escapeLatexInline(stringifyMetaValue(value))}}`)
|
|
292
|
+
}
|
|
293
|
+
lines.push('\\makeatother')
|
|
294
|
+
lines.push('')
|
|
295
|
+
|
|
296
|
+
return lines.join('\n')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Convert a JS key to a LaTeX-safe macro identifier suffix. LaTeX
|
|
301
|
+
* control sequences (post-\makeatletter) accept letters and @; we also
|
|
302
|
+
* lowercase camelCase for readability (`coverImage` → `coverimage`).
|
|
303
|
+
*/
|
|
304
|
+
function toLatexIdent(key) {
|
|
305
|
+
return key.replace(/[A-Z]/g, (m) => m.toLowerCase()).replace(/[^a-z@]/gi, '')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Stringify a metadata value for emission as the body of a \def macro.
|
|
310
|
+
* Strings → as-is. Numbers/booleans → toString. Arrays/objects →
|
|
311
|
+
* comma-joined first-level values (rare for meta; templates that want
|
|
312
|
+
* structured metadata should request it via separate fields).
|
|
313
|
+
*/
|
|
314
|
+
function stringifyMetaValue(value) {
|
|
315
|
+
if (value == null) return ''
|
|
316
|
+
if (typeof value === 'string') return value
|
|
317
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
318
|
+
if (Array.isArray(value)) {
|
|
319
|
+
return value.map(stringifyMetaValue).filter(Boolean).join(', ')
|
|
320
|
+
}
|
|
321
|
+
if (typeof value === 'object') {
|
|
322
|
+
return Object.entries(value)
|
|
323
|
+
.map(([k, v]) => `${k}: ${stringifyMetaValue(v)}`)
|
|
324
|
+
.join('; ')
|
|
325
|
+
}
|
|
326
|
+
return ''
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ============================================================================
|
|
330
|
+
// content.tex — assembled from IR section arrays
|
|
331
|
+
// ============================================================================
|
|
332
|
+
|
|
333
|
+
function emitContent(sections) {
|
|
334
|
+
const parts = [
|
|
335
|
+
'% Document body. Assembled from registered section fragments in',
|
|
336
|
+
'% registration order. Regenerated on every compile(\'latex\').',
|
|
337
|
+
'',
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
sections.forEach((ir, index) => {
|
|
341
|
+
parts.push(`% --- section ${index + 1} ---`)
|
|
342
|
+
parts.push(irNodesToLatex(ir).trimEnd())
|
|
343
|
+
parts.push('')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
return parts.join('\n')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ============================================================================
|
|
350
|
+
// IR → LaTeX source
|
|
351
|
+
// ============================================================================
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Walk an array of block-level IR nodes and concatenate their LaTeX
|
|
355
|
+
* source with blank-line separators.
|
|
356
|
+
*/
|
|
357
|
+
function irNodesToLatex(nodes) {
|
|
358
|
+
if (!nodes || !nodes.length) return ''
|
|
359
|
+
const parts = []
|
|
360
|
+
for (const node of nodes) {
|
|
361
|
+
const emitted = blockNodeToLatex(node)
|
|
362
|
+
if (emitted) parts.push(emitted)
|
|
363
|
+
}
|
|
364
|
+
return parts.join('\n\n') + '\n'
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Convert one block-level IR node to a chunk of LaTeX source.
|
|
369
|
+
* Returns '' for unknown / unsupported node types (walker is additive —
|
|
370
|
+
* new node types just need a new case here).
|
|
371
|
+
*/
|
|
372
|
+
function blockNodeToLatex(node) {
|
|
373
|
+
if (!node || typeof node !== 'object') return ''
|
|
374
|
+
|
|
375
|
+
switch (node.type) {
|
|
376
|
+
case 'text':
|
|
377
|
+
return inlineNodeToLatex(node)
|
|
378
|
+
|
|
379
|
+
case 'link':
|
|
380
|
+
return inlineNodeToLatex(node)
|
|
381
|
+
|
|
382
|
+
case 'paragraph':
|
|
383
|
+
return inlineChildrenToLatex(node.children || []).trim()
|
|
384
|
+
|
|
385
|
+
case 'heading': {
|
|
386
|
+
const level = clampLevel(Number(node.level) || 1)
|
|
387
|
+
const cmd = HEADING_CMD_BY_LEVEL[level]
|
|
388
|
+
const text = inlineChildrenToLatex(node.children || []).trim()
|
|
389
|
+
// \label{id} immediately follows the heading command so
|
|
390
|
+
// \autoref{id} resolves to the heading's number. id flows
|
|
391
|
+
// from the {#sec-id} markdown attribute through the
|
|
392
|
+
// semantic parser and the Sequence walker (preserved on
|
|
393
|
+
// <Heading id=…>) to the IR's data-id attribute.
|
|
394
|
+
const label = node.id ? `\n\\label{${node.id}}` : ''
|
|
395
|
+
return `\\${cmd}{${text}}${label}`
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
case 'chapterOpener': {
|
|
399
|
+
// Foundation's preamble.tex defines \chapteropener{number}{title}{subtitle}.
|
|
400
|
+
// Empty arguments are acceptable; the foundation decides how to
|
|
401
|
+
// render them. Numeric `number` is stringified — LaTeX takes a
|
|
402
|
+
// brace group either way.
|
|
403
|
+
const number = node.number != null ? String(node.number) : ''
|
|
404
|
+
const title = node.title || ''
|
|
405
|
+
const subtitle = node.subtitle || ''
|
|
406
|
+
return `\\chapteropener{${escapeLatexInline(number)}}{${escapeLatexInline(title)}}{${escapeLatexInline(subtitle)}}`
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
case 'codeBlock': {
|
|
410
|
+
// verbatim is in core LaTeX (no package needed). lstlisting /
|
|
411
|
+
// minted are optional upgrades the foundation can layer in via
|
|
412
|
+
// preamble; emitting verbatim keeps the source compilable on
|
|
413
|
+
// any TeX install.
|
|
414
|
+
const text = rawTextFromChildren(node.children || [])
|
|
415
|
+
return `\\begin{verbatim}\n${text}\n\\end{verbatim}`
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
case 'list': {
|
|
419
|
+
const ordered = node.ordered === 'true' || node.ordered === true
|
|
420
|
+
const env = ordered ? 'enumerate' : 'itemize'
|
|
421
|
+
const items = []
|
|
422
|
+
for (const item of node.children || []) {
|
|
423
|
+
if (!item || item.type !== 'listItem') continue
|
|
424
|
+
const itemParts = (item.children || [])
|
|
425
|
+
.map(blockNodeToLatex)
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
if (itemParts.length === 0) {
|
|
428
|
+
items.push(' \\item')
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
const [first, ...rest] = itemParts
|
|
432
|
+
// Indent follow-up blocks by two spaces to keep them
|
|
433
|
+
// visually inside the item — LaTeX itself doesn't care
|
|
434
|
+
// about indentation, but it makes the source readable.
|
|
435
|
+
const firstLines = first.split('\n')
|
|
436
|
+
const indentedFirst = ` \\item ${firstLines[0]}`
|
|
437
|
+
const indentedRest = firstLines
|
|
438
|
+
.slice(1)
|
|
439
|
+
.map((l) => ` ${l}`)
|
|
440
|
+
.join('\n')
|
|
441
|
+
const blockTail = rest
|
|
442
|
+
.map((b) => b.split('\n').map((l) => ` ${l}`).join('\n'))
|
|
443
|
+
.join('\n')
|
|
444
|
+
items.push(
|
|
445
|
+
[indentedFirst, indentedRest, blockTail]
|
|
446
|
+
.filter(Boolean)
|
|
447
|
+
.join('\n'),
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
return `\\begin{${env}}\n${items.join('\n')}\n\\end{${env}}`
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
case 'blockQuote': {
|
|
454
|
+
const inner = (node.children || [])
|
|
455
|
+
.map(blockNodeToLatex)
|
|
456
|
+
.filter(Boolean)
|
|
457
|
+
.join('\n\n')
|
|
458
|
+
return `\\begin{quotation}\n${inner}\n\\end{quotation}`
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
case 'image': {
|
|
462
|
+
const src = node.src
|
|
463
|
+
// Skip images with no resolvable URL — emitting
|
|
464
|
+
// \includegraphics{} aborts pdflatex with "File `' not found".
|
|
465
|
+
// The web reader tolerates empty src gracefully; the PDF
|
|
466
|
+
// shouldn't block compilation either.
|
|
467
|
+
if (!src) return ''
|
|
468
|
+
const width = node.width
|
|
469
|
+
const caption = node.caption
|
|
470
|
+
const id = node.id
|
|
471
|
+
const widthArg = width ? `[width=${toLatexLength(width)}]` : ''
|
|
472
|
+
// Quote the path with braces so spaces / special characters
|
|
473
|
+
// don't trip pdflatex's filename parser.
|
|
474
|
+
const includeStmt = `\\includegraphics${widthArg}{${src}}`
|
|
475
|
+
// \label has to land INSIDE the figure environment and AFTER
|
|
476
|
+
// \caption — LaTeX scopes counter assignments to the
|
|
477
|
+
// immediately preceding \refstepcounter, which \caption
|
|
478
|
+
// triggers. Putting \label before \caption labels the
|
|
479
|
+
// enclosing chapter/section instead of the figure.
|
|
480
|
+
if (caption || id) {
|
|
481
|
+
const lines = ['\\begin{figure}[htbp]', ' \\centering', ` ${includeStmt}`]
|
|
482
|
+
if (caption) lines.push(` \\caption{${escapeLatexInline(caption)}}`)
|
|
483
|
+
if (id) lines.push(` \\label{${id}}`)
|
|
484
|
+
lines.push('\\end{figure}')
|
|
485
|
+
return lines.join('\n')
|
|
486
|
+
}
|
|
487
|
+
return includeStmt
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
case 'table': {
|
|
491
|
+
const columns = Number(node.columns) || detectColumns(node)
|
|
492
|
+
const colSpec = 'l'.repeat(Math.max(1, columns))
|
|
493
|
+
const rows = (node.children || []).filter(
|
|
494
|
+
(c) => c.type === 'tableRow',
|
|
495
|
+
)
|
|
496
|
+
const formattedRows = []
|
|
497
|
+
for (const row of rows) {
|
|
498
|
+
const cells = []
|
|
499
|
+
for (const cell of row.children || []) {
|
|
500
|
+
if (cell.type !== 'tableCell') continue
|
|
501
|
+
const cellText = (cell.children || [])
|
|
502
|
+
.map(blockNodeToLatex)
|
|
503
|
+
.filter(Boolean)
|
|
504
|
+
.join(' ')
|
|
505
|
+
cells.push(cellText)
|
|
506
|
+
}
|
|
507
|
+
if (cells.length > 0) formattedRows.push(cells.join(' & ') + ' \\\\')
|
|
508
|
+
}
|
|
509
|
+
return [
|
|
510
|
+
`\\begin{tabular}{${colSpec}}`,
|
|
511
|
+
` \\hline`,
|
|
512
|
+
...formattedRows.map((r) => ` ${r}`),
|
|
513
|
+
` \\hline`,
|
|
514
|
+
`\\end{tabular}`,
|
|
515
|
+
].join('\n')
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
case 'asterism':
|
|
519
|
+
// Foundation's preamble.tex defines \sectionbreak. Default
|
|
520
|
+
// (DEFAULT_PREAMBLE below) renders as three centered asterisks.
|
|
521
|
+
return '\\sectionbreak'
|
|
522
|
+
|
|
523
|
+
case 'raw':
|
|
524
|
+
// Verbatim pass-through — Raw is the escape hatch for inline
|
|
525
|
+
// LaTeX commands that don't have a builder yet (custom
|
|
526
|
+
// environments, foundation-defined macros).
|
|
527
|
+
return rawTextFromChildren(node.children || [])
|
|
528
|
+
|
|
529
|
+
// Content wrappers: Sequence.jsx emits one. Walk through.
|
|
530
|
+
case 'contentWrapper':
|
|
531
|
+
return irNodesToLatex(node.children || []).trimEnd()
|
|
532
|
+
|
|
533
|
+
default:
|
|
534
|
+
// Unknown types: drop silently. Additive walker.
|
|
535
|
+
return ''
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// LaTeX heading commands by level under \documentclass{book}. Level 1
|
|
540
|
+
// → chapter; deeper levels follow LaTeX's section hierarchy.
|
|
541
|
+
//
|
|
542
|
+
// `report` and `book` classes both have \chapter; `article` does not
|
|
543
|
+
// (use \section as the top level there). Foundations targeting an
|
|
544
|
+
// articleclass should override the template — but the IR-level mapping
|
|
545
|
+
// is book-class by default since this adapter ships alongside
|
|
546
|
+
// @uniweb/book.
|
|
547
|
+
const HEADING_CMD_BY_LEVEL = {
|
|
548
|
+
1: 'chapter',
|
|
549
|
+
2: 'section',
|
|
550
|
+
3: 'subsection',
|
|
551
|
+
4: 'subsubsection',
|
|
552
|
+
5: 'paragraph',
|
|
553
|
+
6: 'subparagraph',
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ============================================================================
|
|
557
|
+
// Inline (paragraph children) → LaTeX source
|
|
558
|
+
// ============================================================================
|
|
559
|
+
|
|
560
|
+
function inlineChildrenToLatex(children) {
|
|
561
|
+
return (children || []).map(inlineNodeToLatex).join('')
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function inlineNodeToLatex(node) {
|
|
565
|
+
if (!node || typeof node !== 'object') return ''
|
|
566
|
+
|
|
567
|
+
if (node.type === 'text') {
|
|
568
|
+
const raw = node.content || ''
|
|
569
|
+
// Code spans skip the body-text escape pass — \texttt arguments
|
|
570
|
+
// inside the brace group still need the LaTeX-special characters
|
|
571
|
+
// protected, but the underlying text is preserved verbatim
|
|
572
|
+
// structurally (no markdown-like asterisks etc. survive here).
|
|
573
|
+
if (node.code === 'true') return `\\texttt{${escapeLatexInline(raw)}}`
|
|
574
|
+
|
|
575
|
+
let text = escapeLatexInline(raw)
|
|
576
|
+
// Apply marks from innermost (text emphasis) outward (font weight).
|
|
577
|
+
// The order doesn't matter semantically for LaTeX, but emit a
|
|
578
|
+
// stable nesting so output diffs cleanly across runs.
|
|
579
|
+
if (node.italics === 'true') text = `\\emph{${text}}`
|
|
580
|
+
if (node.bold === 'true') text = `\\textbf{${text}}`
|
|
581
|
+
if (
|
|
582
|
+
node.underline &&
|
|
583
|
+
(node.underline === 'true' || typeof node.underline === 'object')
|
|
584
|
+
) {
|
|
585
|
+
text = `\\underline{${text}}`
|
|
586
|
+
}
|
|
587
|
+
return text
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (node.type === 'link') {
|
|
591
|
+
const href = node.href || ''
|
|
592
|
+
const inner = inlineChildrenToLatex(node.children || [])
|
|
593
|
+
// \href is supplied by the hyperref package; templates load it.
|
|
594
|
+
// The URL passes through unescaped — hyperref tolerates LaTeX-
|
|
595
|
+
// special characters inside its first argument.
|
|
596
|
+
return `\\href{${href}}{${inner}}`
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return ''
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ============================================================================
|
|
603
|
+
// Helpers
|
|
604
|
+
// ============================================================================
|
|
605
|
+
|
|
606
|
+
function clampLevel(n) {
|
|
607
|
+
if (n < 1) return 1
|
|
608
|
+
if (n > 6) return 6
|
|
609
|
+
return n
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function rawTextFromChildren(children) {
|
|
613
|
+
return (children || [])
|
|
614
|
+
.map((c) => {
|
|
615
|
+
if (c?.type === 'text') return c.content || ''
|
|
616
|
+
if (c?.children) return rawTextFromChildren(c.children)
|
|
617
|
+
return ''
|
|
618
|
+
})
|
|
619
|
+
.join('')
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function detectColumns(tableNode) {
|
|
623
|
+
const firstRow = (tableNode.children || []).find(
|
|
624
|
+
(c) => c?.type === 'tableRow',
|
|
625
|
+
)
|
|
626
|
+
if (!firstRow) return 1
|
|
627
|
+
return (firstRow.children || []).filter((c) => c?.type === 'tableCell')
|
|
628
|
+
.length
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function toLatexLength(width) {
|
|
632
|
+
// Numbers default to points; numeric strings default to points;
|
|
633
|
+
// strings with a unit (pt, mm, cm, in, em, ex, %, …) pass through
|
|
634
|
+
// with one substitution: CSS `%` becomes `\linewidth` since LaTeX
|
|
635
|
+
// doesn't support `%` as a length unit.
|
|
636
|
+
if (typeof width === 'number') return `${width}pt`
|
|
637
|
+
const s = String(width).trim()
|
|
638
|
+
if (/^\d+(?:\.\d+)?$/.test(s)) return `${s}pt`
|
|
639
|
+
const pctMatch = /^(\d+(?:\.\d+)?)\s*%$/.exec(s)
|
|
640
|
+
if (pctMatch) {
|
|
641
|
+
const fraction = (Number(pctMatch[1]) / 100).toFixed(4).replace(/\.?0+$/, '')
|
|
642
|
+
return `${fraction}\\linewidth`
|
|
643
|
+
}
|
|
644
|
+
return s
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The standard LaTeX body-text escape: & % $ # _ { } ~ ^ \ — plus < and
|
|
650
|
+
* > which are not strictly special but are commonly typeset wrong
|
|
651
|
+
* without an upright font (and the `\textless` / `\textgreater`
|
|
652
|
+
* substitutions are safe in any class).
|
|
653
|
+
*
|
|
654
|
+
* Order matters: backslash must be replaced first so the substitution
|
|
655
|
+
* commands we introduce afterwards don't get re-escaped.
|
|
656
|
+
*/
|
|
657
|
+
function escapeChunk(s) {
|
|
658
|
+
return String(s)
|
|
659
|
+
.replace(/\\/g, '\\textbackslash{}')
|
|
660
|
+
.replace(/&/g, '\\&')
|
|
661
|
+
.replace(/%/g, '\\%')
|
|
662
|
+
.replace(/\$/g, '\\$')
|
|
663
|
+
.replace(/#/g, '\\#')
|
|
664
|
+
.replace(/_/g, '\\_')
|
|
665
|
+
.replace(/{/g, '\\{')
|
|
666
|
+
.replace(/}/g, '\\}')
|
|
667
|
+
.replace(/~/g, '\\textasciitilde{}')
|
|
668
|
+
.replace(/\^/g, '\\textasciicircum{}')
|
|
669
|
+
.replace(/</g, '\\textless{}')
|
|
670
|
+
.replace(/>/g, '\\textgreater{}')
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Escape body text, honouring raw-LaTeX sentinel sections. Foundations
|
|
675
|
+
* mark verbatim LaTeX with `markRawLatex(s)` (or by wrapping with
|
|
676
|
+
* RAW_BEGIN / RAW_END directly); the inner contents pass through
|
|
677
|
+
* unescaped, and the sentinel characters are stripped on emission.
|
|
678
|
+
*
|
|
679
|
+
* Fast path (no sentinel present): identical to the simple escape.
|
|
680
|
+
* Slow path (sentinels present): split into alternating regular / raw
|
|
681
|
+
* segments, escape only the regular ones.
|
|
682
|
+
*/
|
|
683
|
+
function escapeLatexInline(s) {
|
|
684
|
+
const str = String(s)
|
|
685
|
+
if (!str.includes(RAW_BEGIN)) return escapeChunk(str)
|
|
686
|
+
|
|
687
|
+
const parts = str.split(RAW_BEGIN)
|
|
688
|
+
const out = [escapeChunk(parts[0])]
|
|
689
|
+
for (let i = 1; i < parts.length; i++) {
|
|
690
|
+
const seg = parts[i]
|
|
691
|
+
const endIdx = seg.indexOf(RAW_END)
|
|
692
|
+
if (endIdx === -1) {
|
|
693
|
+
// Unterminated sentinel — defensive fallback, escape the
|
|
694
|
+
// whole tail so we don't leak a bare U+E000 into output.
|
|
695
|
+
out.push(escapeChunk(seg))
|
|
696
|
+
} else {
|
|
697
|
+
// Verbatim segment — emit unescaped.
|
|
698
|
+
out.push(seg.slice(0, endIdx))
|
|
699
|
+
// Anything after the closing sentinel is regular text again.
|
|
700
|
+
out.push(escapeChunk(seg.slice(endIdx + 1)))
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return out.join('')
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ============================================================================
|
|
707
|
+
// Defaults — minimal templates usable without a foundation override
|
|
708
|
+
// ============================================================================
|
|
709
|
+
|
|
710
|
+
export const DEFAULT_PREAMBLE = `% Minimal default preamble.
|
|
711
|
+
% Foundations should replace this with their own preamble.tex.
|
|
712
|
+
|
|
713
|
+
% Asterism — three centered asterisks as a section break.
|
|
714
|
+
\\newcommand{\\sectionbreak}{%
|
|
715
|
+
\\par\\bigskip\\centerline{\\Large *\\quad *\\quad *}\\bigskip\\par%
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
% Chapter opener — falls back to LaTeX's built-in \\chapter command when
|
|
719
|
+
% the foundation hasn't defined a richer renderer. Number argument is
|
|
720
|
+
% currently ignored (LaTeX numbers chapters automatically); subtitle is
|
|
721
|
+
% emitted as a small italicised line below the title when non-empty.
|
|
722
|
+
\\providecommand{\\chapteropener}[3]{%
|
|
723
|
+
\\chapter{#2}%
|
|
724
|
+
\\ifx&\\else{\\par\\medskip\\noindent\\itshape #3\\par\\medskip}\\fi%
|
|
725
|
+
}
|
|
726
|
+
`
|
|
727
|
+
|
|
728
|
+
export const DEFAULT_TEMPLATE = `% Minimal default template.
|
|
729
|
+
% Foundations should replace this with their own template.tex.
|
|
730
|
+
\\documentclass[11pt]{book}
|
|
731
|
+
|
|
732
|
+
\\usepackage[utf8]{inputenc}
|
|
733
|
+
\\usepackage[T1]{fontenc}
|
|
734
|
+
\\usepackage{lmodern}
|
|
735
|
+
\\usepackage[a4paper, margin=1in]{geometry}
|
|
736
|
+
\\usepackage{graphicx}
|
|
737
|
+
\\usepackage{hyperref}
|
|
738
|
+
\\usepackage{xcolor}
|
|
739
|
+
\\hypersetup{colorlinks=true, linkcolor=black, urlcolor=blue!50!black}
|
|
740
|
+
|
|
741
|
+
\\setcounter{tocdepth}{2}
|
|
742
|
+
\\setcounter{secnumdepth}{2}
|
|
743
|
+
`
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @uniweb/press/latex — React builder components for LaTeX documents.
|
|
3
|
+
*
|
|
4
|
+
* The data-* attribute vocabulary is identical to the Typst builders
|
|
5
|
+
* (paragraph, heading, list, blockQuote, image, table, chapterOpener,
|
|
6
|
+
* codeBlock, asterism, raw, sequence, text, link). Both adapters walk
|
|
7
|
+
* the same HTML-IR; the difference is downstream — typst.js emits
|
|
8
|
+
* `*bold*` and `\=`-style headings, latex.js emits `\textbf{...}` and
|
|
9
|
+
* `\section{...}`.
|
|
10
|
+
*
|
|
11
|
+
* Per `docs/architecture/adding-a-format.md` §"Worked example: LaTeX",
|
|
12
|
+
* builders that share an attribute set with another HTML-based adapter
|
|
13
|
+
* can be thin re-exports rather than parallel implementations. We
|
|
14
|
+
* follow that here. Foundations writing JSX import from
|
|
15
|
+
* `@uniweb/press/latex`; the same JSX produces a LaTeX bundle when
|
|
16
|
+
* `compile('latex')` runs.
|
|
17
|
+
*
|
|
18
|
+
* The format adapter (compileLatex, buildBundle) lives at
|
|
19
|
+
* src/adapters/latex.js and is intentionally NOT part of this barrel —
|
|
20
|
+
* adapters are reached only via the dynamic-import inside
|
|
21
|
+
* useDocumentCompile, so importing from '@uniweb/press/latex' does not
|
|
22
|
+
* pull in the adapter or its ZIP helper.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
TextRun,
|
|
27
|
+
Paragraph,
|
|
28
|
+
Paragraphs,
|
|
29
|
+
Heading,
|
|
30
|
+
H1,
|
|
31
|
+
H2,
|
|
32
|
+
H3,
|
|
33
|
+
H4,
|
|
34
|
+
H5,
|
|
35
|
+
H6,
|
|
36
|
+
ChapterOpener,
|
|
37
|
+
CodeBlock,
|
|
38
|
+
List,
|
|
39
|
+
BulletList,
|
|
40
|
+
NumberedList,
|
|
41
|
+
BlockQuote,
|
|
42
|
+
Image,
|
|
43
|
+
Table,
|
|
44
|
+
Tr,
|
|
45
|
+
Td,
|
|
46
|
+
Asterism,
|
|
47
|
+
Raw,
|
|
48
|
+
Sequence,
|
|
49
|
+
} from '../typst/index.js'
|
|
50
|
+
|
|
51
|
+
// markRawLatex / sentinels — exposed so foundation code can wrap raw
|
|
52
|
+
// LaTeX strings that the adapter should pass through its body-text
|
|
53
|
+
// escape pass unchanged. See src/latex/raw.js for rationale.
|
|
54
|
+
export { markRawLatex, RAW_BEGIN, RAW_END } from './raw.js'
|
package/src/latex/raw.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw-LaTeX passthrough sentinels.
|
|
3
|
+
*
|
|
4
|
+
* Foundation code that needs to inject verbatim LaTeX into a paragraph's
|
|
5
|
+
* `text` field (e.g. `\cite{darwin1859}` substituted for an inline cite
|
|
6
|
+
* inset) wraps the LaTeX between these Unicode private-use-area
|
|
7
|
+
* characters. The adapter's `escapeLatexInline` pass leaves the
|
|
8
|
+
* sentinel-bracketed segments unescaped and strips the sentinel
|
|
9
|
+
* characters themselves before emission.
|
|
10
|
+
*
|
|
11
|
+
* Why a sentinel rather than an IR-level marker: the substitution
|
|
12
|
+
* happens before Sequence builds JSX (foundation calls
|
|
13
|
+
* `resolveInlineInsets` on the content sequence), so the raw text lands
|
|
14
|
+
* in a paragraph's HTML string. The IR walker has no per-character
|
|
15
|
+
* provenance — by the time we emit, we can't tell "this `\` came from a
|
|
16
|
+
* cite substitution" from "this `\` came from authored prose." The
|
|
17
|
+
* sentinel carries that provenance through unchanged.
|
|
18
|
+
*
|
|
19
|
+
* The characters are PUA: U+E000 (start) and U+E001 (end). Authors
|
|
20
|
+
* never type these; HTML / React / parse5 all treat them as ordinary
|
|
21
|
+
* characters and round-trip them unchanged.
|
|
22
|
+
*
|
|
23
|
+
* Lives here (rather than in src/adapters/latex.js) because the
|
|
24
|
+
* adapter must stay dynamic-imported only — but foundations need to
|
|
25
|
+
* statically import `markRawLatex` to use it during render. The adapter
|
|
26
|
+
* imports the sentinel constants from this file too, so there is one
|
|
27
|
+
* source of truth.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const RAW_BEGIN = ''
|
|
31
|
+
export const RAW_END = ''
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Wrap a string of verbatim LaTeX so the adapter's escape pass passes
|
|
35
|
+
* it through unchanged. Foundations call this when substituting
|
|
36
|
+
* commands into paragraph text (cite substitution, future xref
|
|
37
|
+
* substitution, etc.).
|
|
38
|
+
*/
|
|
39
|
+
export function markRawLatex(s) {
|
|
40
|
+
return RAW_BEGIN + String(s) + RAW_END
|
|
41
|
+
}
|
package/src/typst/Heading.jsx
CHANGED
|
@@ -8,15 +8,16 @@
|
|
|
8
8
|
import { parseStyledString } from '../docx/parseStyledString.js'
|
|
9
9
|
import TextRun from './TextRun.jsx'
|
|
10
10
|
|
|
11
|
-
export default function Heading({ level = 1, data, children, ...props }) {
|
|
11
|
+
export default function Heading({ level = 1, id, data, children, ...props }) {
|
|
12
12
|
const clamped = Math.min(Math.max(level, 1), 6)
|
|
13
13
|
const Tag = `h${clamped}`
|
|
14
|
+
const idAttr = id ? { 'data-id': id } : {}
|
|
14
15
|
|
|
15
16
|
if (data) {
|
|
16
17
|
const parts = parseStyledString(data)
|
|
17
18
|
|
|
18
19
|
return (
|
|
19
|
-
<Tag data-type="heading" data-level={clamped} {...props}>
|
|
20
|
+
<Tag data-type="heading" data-level={clamped} {...idAttr} {...props}>
|
|
20
21
|
{parts.map((part, i) =>
|
|
21
22
|
part.type === 'link' ? (
|
|
22
23
|
<a
|
|
@@ -44,7 +45,7 @@ export default function Heading({ level = 1, data, children, ...props }) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
return (
|
|
47
|
-
<Tag data-type="heading" data-level={clamped} {...props}>
|
|
48
|
+
<Tag data-type="heading" data-level={clamped} {...idAttr} {...props}>
|
|
48
49
|
{children}
|
|
49
50
|
</Tag>
|
|
50
51
|
)
|
package/src/typst/Image.jsx
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Image / figure. Emits <img data-type="image"> with src and optional alt,
|
|
3
|
-
* width, caption. The adapter fetches, hashes, and rewrites src to
|
|
3
|
+
* width, caption, id. The adapter fetches, hashes, and rewrites src to
|
|
4
4
|
* assets/<hash>.<ext> in the output bundle, and emits:
|
|
5
5
|
*
|
|
6
6
|
* #figure(image("assets/<hash>.<ext>", width: <width>), caption: [<caption>])
|
|
7
|
+
*
|
|
8
|
+
* The optional `id` flows through as data-id so format adapters that
|
|
9
|
+
* support cross-references can emit a label (LaTeX `\label{id}`,
|
|
10
|
+
* Typst `<id>`) on the figure environment.
|
|
7
11
|
*/
|
|
8
|
-
export default function Image({ src, alt, width, caption, ...props }) {
|
|
12
|
+
export default function Image({ src, alt, width, caption, id, ...props }) {
|
|
9
13
|
const attrs = { 'data-type': 'image' }
|
|
10
14
|
if (src) attrs['data-src'] = src
|
|
11
15
|
if (alt) attrs.alt = alt
|
|
12
16
|
if (width) attrs['data-width'] = width
|
|
13
17
|
if (caption) attrs['data-caption'] = caption
|
|
18
|
+
if (id) attrs['data-id'] = id
|
|
14
19
|
|
|
15
20
|
return <img src={src} {...attrs} {...props} />
|
|
16
21
|
}
|
package/src/typst/Sequence.jsx
CHANGED
|
@@ -40,7 +40,12 @@ function renderElement(element, key) {
|
|
|
40
40
|
switch (element.type) {
|
|
41
41
|
case 'heading':
|
|
42
42
|
return (
|
|
43
|
-
<Heading
|
|
43
|
+
<Heading
|
|
44
|
+
key={key}
|
|
45
|
+
level={element.level || 1}
|
|
46
|
+
id={element.attrs?.id || undefined}
|
|
47
|
+
data={element.text || ''}
|
|
48
|
+
/>
|
|
44
49
|
)
|
|
45
50
|
|
|
46
51
|
case 'paragraph':
|
|
@@ -83,6 +88,7 @@ function renderElement(element, key) {
|
|
|
83
88
|
alt={element.attrs?.alt}
|
|
84
89
|
caption={element.attrs?.caption}
|
|
85
90
|
width={element.attrs?.width}
|
|
91
|
+
id={element.attrs?.id || undefined}
|
|
86
92
|
/>
|
|
87
93
|
)
|
|
88
94
|
|