bookcover-web 0.3.0 → 0.5.0

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,6 +1,6 @@
1
1
  {
2
2
  "name": "bookcover-web",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT-0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,9 @@
12
12
  "type": "module",
13
13
  "main": "dist/index.js",
14
14
  "types": "dist/index.d.ts",
15
+ "files": [
16
+ "dist/"
17
+ ],
15
18
  "scripts": {
16
19
  "build": "tsc",
17
20
  "dev": "tsc --watch"
@@ -20,7 +23,7 @@
20
23
  "@myriaddreamin/typst-ts-renderer": "^0.7.0",
21
24
  "@myriaddreamin/typst-ts-web-compiler": "^0.7.0",
22
25
  "@myriaddreamin/typst.ts": "^0.7.0",
23
- "bookcover-core": "^0.3.0",
26
+ "bookcover-core": "^0.5.0",
24
27
  "typst-fonts": "^0.1.0"
25
28
  }
26
29
  }
@@ -1,42 +0,0 @@
1
-
2
- // Protocol types for embedding the cover-editor widget in an iframe. The host sends one
3
- // InitMessage in reply to the widget's 'ready' message; the widget reports edits back with
4
- // WidgetMessage. Binaries (background image, custom font bytes) always travel as separate
5
- // structured-clone fields beside the pure-JSON form values, so hosts can persist `data`
6
- // as-is (e.g. to Firestore) and store the binaries elsewhere.
7
-
8
- import type {EmbedFormState} from 'bookcover-core'
9
- import type {CustomFont} from 'typst-fonts'
10
-
11
- /** Locales the widget UI supports. Lives in this published package, so adding a widget
12
- * locale requires a bookcover-web release. */
13
- export type AppLocale = 'eng' | 'vie'
14
-
15
- /** Host -> widget, answered to the widget's 'ready' message */
16
- export interface InitMessage {
17
- type: 'init'
18
- // Form values to preset — pure JSON, no binaries inside
19
- preset?: Partial<EmbedFormState>
20
- // Background image to restore into the editor (File structured-clones as a cheap blob ref)
21
- bg_image?: File | null
22
- // Previously uploaded font families to restore into the editor
23
- custom_fonts?: CustomFont[]
24
- // Swap the export button for a "Finished" signal, and show a Cancel button
25
- finished_mode?: boolean
26
- // Hide the Book Size sidebar section entirely
27
- hide_size_section?: boolean
28
- locale?: AppLocale
29
- }
30
-
31
- /** Widget -> host. `schema` is the renderable generator schema derived from `data` (also
32
- * derivable host-side via build_schema). `bg_image` rides on every data/finished message
33
- * (cloning a File is cheap); `custom_fonts` byte arrays are expensive to clone, so on 'data'
34
- * messages the field is only present when the font set changed since the last message
35
- * (absent = unchanged), while 'finished' always carries the complete array. */
36
- export type WidgetMessage =
37
- | {type: 'ready'}
38
- | {type: 'data', data: EmbedFormState, schema: Record<string, unknown>,
39
- bg_image: File | null, custom_fonts?: CustomFont[]}
40
- | {type: 'finished', data: EmbedFormState, schema: Record<string, unknown>,
41
- bg_image: File | null, custom_fonts: CustomFont[]}
42
- | {type: 'cancelled'}
package/src/index.ts DELETED
@@ -1,421 +0,0 @@
1
-
2
- // Browser cover generator — compiles typst files via WebAssembly, supports PDF/SVG/PNG output
3
-
4
- import {createTypstCompiler, CompileFormatEnum} from '@myriaddreamin/typst.ts/compiler'
5
- import {createTypstRenderer} from '@myriaddreamin/typst.ts/renderer'
6
- import type {TypstCompiler} from '@myriaddreamin/typst.ts/compiler'
7
- import type {TypstRenderer} from '@myriaddreamin/typst.ts/renderer'
8
- import {loadFonts} from '@myriaddreamin/typst.ts'
9
- import {build, cover_schema, split_svg, split_png, split_pdf, frame_image, frame_asset_path,
10
- asset_path, DOCS_DIR, TEMPLATE_FILES, collect_all_fonts} from 'bookcover-core'
11
- import type {OutputFormat, SplitResult, Templates} from 'bookcover-core'
12
- import {base_font} from 'typst-fonts'
13
- import {load_fonts_prefix, font_urls_for as build_font_urls, fetch_font_bytes,
14
- fonts_to_blob_urls, revoke_blob_urls} from 'typst-fonts/web'
15
-
16
- export type {CoverSchema, TitlePosition, FontConfig,
17
- OutputFormat, SplitResult, PatternDef} from 'bookcover-core'
18
- export type {BundledFont, CjkVariant, CustomFont} from 'typst-fonts'
19
- export {font_file_url} from 'typst-fonts/web'
20
- export {get_fonts, get_bundled_font} from 'typst-fonts'
21
- export {list_patterns, collect_fonts, collect_all_fonts, default_spine_title} from 'bookcover-core'
22
-
23
- // Form state + form->schema conversion, so hosts can derive the renderable schema themselves
24
- export {make_blank_form_values, build_schema, curly_quotes, parse_font_family, find_pattern,
25
- derive_colors, hex_override_to_hsl, hex_to_hsl, is_dark_color} from 'bookcover-core'
26
- export type {FormState, EmbedFormState, CustomFontStyle, DerivedColors} from 'bookcover-core'
27
-
28
- // Embed protocol types for iframing the widget
29
- export type {InitMessage, WidgetMessage, AppLocale} from './embed_types.js'
30
-
31
- const decoder = new TextDecoder()
32
-
33
- export interface InitOptions {
34
- // URL or path to typst_ts_web_compiler_bg.wasm — required
35
- wasm_url:string
36
- // URL or path to typst_ts_renderer_bg.wasm — required for SVG/PNG output
37
- renderer_wasm_url?:string
38
- // URL prefix for the static assets tree (e.g. '/generator_assets/' in dev,
39
- // 'https://assets.paper.bible/' in production). Used to load typst templates (docs/),
40
- // frames, backgrounds, etc.
41
- assets_prefix?:string
42
- // URL prefix for fonts — curated and Noto fallback alike (e.g. '/generator_assets/fonts'
43
- // in dev, 'https://assets.paper.bible/fonts' in production). Kept separate from
44
- // assets_prefix since the fonts tree is published separately from the app (see the repo's
45
- // .bin/download_fonts / .bin/deploy_fonts).
46
- fonts_prefix?:string
47
- }
48
-
49
- export interface GenerateOptions {
50
- schema:unknown
51
- // Background image as a Blob (content type included)
52
- image?:Blob
53
- // Output format: 'pdf' (default), 'svg', or 'png'
54
- format?:OutputFormat
55
- // PPI for PNG output (default 144)
56
- ppi?:number
57
- // Whether to split the result into front/back/spine panels
58
- split?:boolean
59
- // Raw TTF bytes for user-uploaded custom fonts
60
- custom_fonts?:Uint8Array[]
61
- }
62
-
63
- export interface GenerateResult {
64
- // The full cover data (PDF/PNG bytes, or SVG string)
65
- data:Uint8Array | string
66
- // Split panel data (only present when split is true and format is svg/png)
67
- split?:SplitResult<Uint8Array> | SplitResult<string>
68
- }
69
-
70
-
71
- /** Format compilation diagnostics into a readable error */
72
- function throw_compile_error(diagnostics:unknown):never {
73
- const diag = Array.isArray(diagnostics)
74
- ? diagnostics.map((d) => (typeof d === 'string' ? d : JSON.stringify(d))).join('\n')
75
- : 'unknown error'
76
- throw new Error(`[generator-web] Typst compilation failed:\n${diag}`)
77
- }
78
-
79
- /** Create a 2D canvas — a DOM element on the main thread, OffscreenCanvas inside a worker */
80
- function make_canvas(width:number, height:number):HTMLCanvasElement | OffscreenCanvas {
81
- if (typeof document === 'undefined') {
82
- return new OffscreenCanvas(width, height)
83
- }
84
- const canvas = document.createElement('canvas')
85
- canvas.width = width
86
- canvas.height = height
87
- return canvas
88
- }
89
-
90
- /** Encode a canvas' contents as PNG bytes (handles both canvas types) */
91
- async function canvas_png_bytes(canvas:HTMLCanvasElement | OffscreenCanvas):Promise<Uint8Array> {
92
- const blob = 'convertToBlob' in canvas
93
- ? await canvas.convertToBlob({type: 'image/png'})
94
- : await new Promise<Blob>((resolve) => {
95
- canvas.toBlob((b) => resolve(b!), 'image/png')
96
- })
97
- return new Uint8Array(await blob.arrayBuffer())
98
- }
99
-
100
- /** Crop a region from a PNG using the Canvas API — used as PngCropFn */
101
- async function canvas_crop(
102
- data:Uint8Array,
103
- x:number,
104
- y:number,
105
- w:number,
106
- h:number,
107
- ):Promise<Uint8Array> {
108
- const blob = new Blob([data as BlobPart], {type: 'image/png'})
109
- const bitmap = await createImageBitmap(blob)
110
-
111
- const canvas = make_canvas(w, h)
112
- const ctx = (canvas as HTMLCanvasElement).getContext('2d')!
113
- ctx.drawImage(bitmap, x, y, w, h, 0, 0, w, h)
114
- bitmap.close()
115
-
116
- return canvas_png_bytes(canvas)
117
- }
118
-
119
- /** Stateful cover generator — each instance owns its own compiler and renderer */
120
- export class CoverGenerator {
121
- private opts:InitOptions
122
- // Created lazily on first generate() — the font set isn't known until a schema arrives,
123
- // so creating one earlier would just instantiate WASM with fonts that get replaced
124
- private compiler!:TypstCompiler
125
- private renderer:TypstRenderer | null
126
-
127
- // Cache key of the font set the current compiler was init'd with ('' = no compiler yet)
128
- private active_fonts = ''
129
- // Fetched font bytes keyed by URL, so a compiler reinit (any font-set change) never
130
- // refetches families already seen this session
131
- private font_bytes = new Map<string, Uint8Array>()
132
- // Blob URLs handed to the current compiler (bundled + custom) — revoked on reinit
133
- private font_blob_urls:string[] = []
134
- // Identity-based ids for custom font byte arrays (see custom_font_id)
135
- private custom_font_ids = new WeakMap<Uint8Array, number>()
136
- private next_custom_font_id = 1
137
-
138
- constructor(opts:InitOptions, renderer:TypstRenderer | null) {
139
- this.opts = opts
140
- this.renderer = renderer
141
- }
142
-
143
- /** Build fetch URLs for the given font families against fonts_prefix — curated families
144
- * live at <prefix>/<family>/, Noto fallback families at <prefix>/_noto/<family>/,
145
- * mirroring the top-level fonts/ directory's own layout exactly */
146
- private font_urls_for(families:string[]):string[] {
147
- return build_font_urls(this.opts.fonts_prefix ?? '/fonts/', families)
148
- }
149
-
150
- /** Fetch a font file's bytes, memoised in font_bytes (failures aren't cached, so a
151
- * later generate retries the fetch) */
152
- private async fetch_font(url:string):Promise<Uint8Array> {
153
- const cached = this.font_bytes.get(url)
154
- if (cached) {
155
- return cached
156
- }
157
- const bytes = await fetch_font_bytes(url)
158
- this.font_bytes.set(url, bytes)
159
- return bytes
160
- }
161
-
162
- /** Pre-fetch the given font families into the in-memory byte cache */
163
- async prefetch_fonts(families:string[]):Promise<void> {
164
- await Promise.all(this.font_urls_for(families).map(url => this.fetch_font(url)))
165
- }
166
-
167
- /** Stable id for a custom font's byte array. The widget passes the same Uint8Array
168
- * references across generates, so object identity distinguishes fonts without hashing
169
- * their bytes (byteLength alone could collide between two different fonts). */
170
- private custom_font_id(data:Uint8Array):number {
171
- let id = this.custom_font_ids.get(data)
172
- if (id === undefined) {
173
- id = this.next_custom_font_id
174
- this.next_custom_font_id += 1
175
- this.custom_font_ids.set(data, id)
176
- }
177
- return id
178
- }
179
-
180
- /** (Re)initialise the compiler with a different font set. Bundled font bytes come from
181
- * the in-memory cache (fetched at most once per session) and everything is handed to
182
- * loadFonts() as blob URLs, keeping the network out of the reinit path. The renderer
183
- * never needs reinitialisation because it reads glyph outlines from compiled vector
184
- * data. */
185
- private async reinit_compiler(font_urls:string[], custom_fonts?:Uint8Array[]):Promise<void> {
186
- // Fetch any fonts not yet cached, in parallel
187
- const bundled_bytes = await Promise.all(font_urls.map(url => this.fetch_font(url)))
188
-
189
- // Revoke the previous compiler's blob URLs to avoid memory leaks
190
- revoke_blob_urls(this.font_blob_urls)
191
-
192
- // One blob URL per font file (bundled + custom) for loadFonts() to read —
193
- // mapShadow only adds to the virtual filesystem, not the font book
194
- this.font_blob_urls = fonts_to_blob_urls([...bundled_bytes, ...(custom_fonts ?? [])])
195
-
196
- // assets:false stops typst.ts silently appending its default text fonts (NewCM,
197
- // Libertinus, DejaVu... fetched from jsdelivr) — all fonts we need are passed explicitly
198
- const compiler_font_opts = loadFonts(this.font_blob_urls, {assets: false})
199
-
200
- const c = createTypstCompiler()
201
- await c.init({
202
- getModule: () => ({module_or_path: this.opts.wasm_url}),
203
- beforeBuild: [compiler_font_opts],
204
- })
205
-
206
- this.compiler = c
207
- }
208
-
209
- /** Load the typst file map into the compiler's shadow filesystem */
210
- private load_files(files:Map<string, Uint8Array>):void {
211
- this.compiler.resetShadow()
212
- for (const [filename, bytes] of files) {
213
- const vpath = `/${filename}`
214
- if (filename.endsWith('.typ')) {
215
- this.compiler.addSource(vpath, decoder.decode(bytes))
216
- }
217
- else {
218
- this.compiler.mapShadow(vpath, bytes)
219
- }
220
- }
221
- }
222
-
223
- /** Compile to PDF and return raw bytes */
224
- private async compile_pdf():Promise<Uint8Array> {
225
- const result = await this.compiler.compile({
226
- mainFilePath: '/cover.typ',
227
- format: CompileFormatEnum.pdf,
228
- })
229
- if (!result.result) {
230
- throw_compile_error(result.diagnostics)
231
- }
232
- return result.result!
233
- }
234
-
235
- /** Compile to vector format, then render as SVG string covering the full canvas */
236
- private async compile_svg():Promise<string> {
237
- this.assert_renderer('SVG')
238
- const result = await this.compiler.compile({
239
- mainFilePath: '/cover.typ',
240
- format: CompileFormatEnum.vector,
241
- })
242
- if (!result.result) {
243
- throw_compile_error(result.diagnostics)
244
- }
245
-
246
- const svg = await this.renderer!.renderSvg({
247
- artifactContent: result.result!,
248
- format: 'vector',
249
- data_selection: {js: false, css: true, body: true, defs: true},
250
- })
251
-
252
- // Patch unitless width/height to add `pt` suffix (Typst SVG output uses pt) so
253
- // browsers render at 96 CSS px per inch, matching the split SVGs from split_svg()
254
- return svg.replace(/<svg([^>]*)>/, (_match, attrs:string) => {
255
- const patched = attrs
256
- .replace(/width="([\d.]+)"/, 'width="$1pt"')
257
- .replace(/height="([\d.]+)"/, 'height="$1pt"')
258
- return `<svg${patched}>`
259
- })
260
- }
261
-
262
- /** Compile to vector format, then render as PNG via offscreen canvas */
263
- private async compile_png(ppi:number):Promise<Uint8Array> {
264
- this.assert_renderer('PNG')
265
- const result = await this.compiler.compile({
266
- mainFilePath: '/cover.typ',
267
- format: CompileFormatEnum.vector,
268
- })
269
- if (!result.result) {
270
- throw_compile_error(result.diagnostics)
271
- }
272
-
273
- // Render to a canvas at the desired PPI (typst uses 72pt/inch)
274
- const scale = ppi / 72
275
- const canvas = document.createElement('canvas')
276
-
277
- await this.renderer!.renderCanvas({
278
- artifactContent: result.result!,
279
- format: 'vector',
280
- canvas,
281
- pageOffset: 0,
282
- pixelPerPt: scale,
283
- backgroundColor: '#ffffff',
284
- })
285
-
286
- // Extract PNG from canvas
287
- const blob = await new Promise<Blob>((resolve) => {
288
- canvas.toBlob((b) => resolve(b!), 'image/png')
289
- })
290
- return new Uint8Array(await blob.arrayBuffer())
291
- }
292
-
293
- /** Throw if the renderer was not initialised */
294
- private assert_renderer(format:string):void {
295
- if (!this.renderer) {
296
- throw new Error(
297
- `[generator-web] ${format} output requires renderer_wasm_url in init() options`
298
- )
299
- }
300
- }
301
-
302
- /** Generate a book cover using this instance's compiler and renderer */
303
- async generate(options:GenerateOptions):Promise<GenerateResult> {
304
- const format = options.format ?? 'pdf'
305
- // Default to 144 PPI (2x typographic resolution, 2 × 72pt/in)
306
- const ppi = options.ppi ?? 144
307
- const split = options.split ?? false
308
-
309
- // Parse schema and get dimensions from printing-services
310
- const parsed = cover_schema.parse(options.schema)
311
- const assets = this.opts.assets_prefix ?? '/assets/'
312
-
313
- // (Re)initialise the compiler when the set of required fonts changes — including on
314
- // the very first generate, since the compiler is created lazily ('' never matches).
315
- // Noto Serif (base font) is always included — it's not bundled in the WASM assets.
316
- // collect_all_fonts also includes every Noto fallback family needed to cover non-Latin
317
- // scripts detected in the schema's text, so only those specific families get fetched.
318
- const needed_fonts = collect_all_fonts(parsed)
319
- const custom_fonts_id = options.custom_fonts
320
- ? options.custom_fonts.map(b => String(this.custom_font_id(b))).join(':')
321
- : ''
322
- const cache_key = needed_fonts.join(',') + '|' + custom_fonts_id
323
- if (cache_key !== this.active_fonts) {
324
- await this.reinit_compiler(this.font_urls_for(needed_fonts), options.custom_fonts)
325
- this.active_fonts = cache_key
326
- }
327
-
328
- // Convert Blob to ImageInput (Uint8Array + extension) for the core builder
329
- let image_input:{data:Uint8Array, ext:string} | undefined
330
- if (options.image) {
331
- const blob = options.image
332
- const ext = '.' + (blob.type.split('/')[1] ?? 'jpg').replace('jpeg', 'jpg')
333
- image_input = {data: new Uint8Array(await blob.arrayBuffer()), ext}
334
- }
335
-
336
- // Load typst templates from assets (docs/ subdir)
337
- const [cover_typ, helpers_typ] = await Promise.all([
338
- fetch(asset_path(assets, DOCS_DIR, TEMPLATE_FILES.cover)).then(r => r.text()),
339
- fetch(asset_path(assets, DOCS_DIR, TEMPLATE_FILES.helpers)).then(r => r.text()),
340
- ])
341
- const templates:Templates = {cover: cover_typ, helpers: helpers_typ}
342
-
343
- // Load the frame PNG from assets when the schema uses painted coverage
344
- let frame_blob:Blob | undefined
345
- if (parsed.bg_image_coverage === 'painted') {
346
- const url = frame_asset_path(assets, 'painted')
347
- const resp = await fetch(url)
348
- frame_blob = await resp.blob()
349
- }
350
-
351
- const {files, dims} = await build(templates, parsed, image_input, frame_image, frame_blob)
352
- this.load_files(files)
353
-
354
- // Compile to the requested format
355
- let data:Uint8Array | string
356
-
357
- if (format === 'pdf') {
358
- data = await this.compile_pdf()
359
- }
360
- else if (format === 'svg') {
361
- data = await this.compile_svg()
362
- }
363
- else {
364
- data = await this.compile_png(ppi)
365
- }
366
-
367
- const result:GenerateResult = {data}
368
-
369
- // Split into front/back/spine panels
370
- if (split) {
371
- if (format === 'pdf') {
372
- result.split = split_pdf(data as Uint8Array, dims)
373
- }
374
- else if (format === 'svg') {
375
- result.split = split_svg(data as string, dims)
376
- }
377
- else {
378
- result.split = await split_png(data as Uint8Array, dims, ppi, canvas_crop)
379
- }
380
- }
381
-
382
- return result
383
- }
384
-
385
- /** Revoke blob URLs and drop cached font bytes to free memory */
386
- destroy():void {
387
- revoke_blob_urls(this.font_blob_urls)
388
- this.font_blob_urls = []
389
- this.font_bytes.clear()
390
- }
391
- }
392
-
393
- /**
394
- * Create a CoverGenerator instance with an optional initialised WASM renderer.
395
- * The compiler itself is created lazily by the first generate() call, once the schema's
396
- * font set is known. Each instance is independent — multiple generators can run concurrently.
397
- */
398
- export async function init(options:InitOptions):Promise<CoverGenerator> {
399
- // Load the curated font manifest before anything else below resolves a font family
400
- await load_fonts_prefix(options.fonts_prefix ?? '/fonts/')
401
-
402
- // Renderer only needs base fonts (glyph shapes are embedded in compiled vector data)
403
- let renderer:TypstRenderer | null = null
404
- if (options.renderer_wasm_url) {
405
- const renderer_font_opts = loadFonts([])
406
- const r = createTypstRenderer()
407
- await r.init({
408
- getModule: () => ({module_or_path: options.renderer_wasm_url!}),
409
- beforeBuild: [renderer_font_opts],
410
- })
411
- renderer = r
412
- }
413
-
414
- const gen = new CoverGenerator(options, renderer)
415
-
416
- // Warm the font byte cache with the always-needed base font while the rest of the app
417
- // finishes loading — a failure here is fine, the first generate simply refetches
418
- gen.prefetch_fonts([base_font()]).catch(() => {})
419
-
420
- return gen
421
- }
package/tsconfig.json DELETED
@@ -1,19 +0,0 @@
1
-
2
- {
3
- "compilerOptions": {
4
- "target": "ES2022",
5
- "module": "NodeNext",
6
- "moduleResolution": "NodeNext",
7
- "lib": ["ES2022", "DOM"],
8
- "outDir": "dist",
9
- "rootDir": "src",
10
- "strict": true,
11
- "declaration": true,
12
- "declarationMap": true,
13
- "sourceMap": true,
14
- "forceConsistentCasingInFileNames": true,
15
- "skipLibCheck": true
16
- },
17
- "include": ["src"],
18
- "exclude": ["node_modules", "dist"]
19
- }
@@ -1 +0,0 @@
1
- {"root":["./src/index.ts"],"version":"5.9.3"}