@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 CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@uniweb/press",
3
- "version": "0.4.2",
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"
@@ -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&#3&\\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'
@@ -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
+ }
@@ -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
  )
@@ -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
  }
@@ -40,7 +40,12 @@ function renderElement(element, key) {
40
40
  switch (element.type) {
41
41
  case 'heading':
42
42
  return (
43
- <Heading key={key} level={element.level || 1} data={element.text || ''} />
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