bookcover-core 0.19.0 → 0.20.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/dist/data_file.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Serialise all computed cover data into a Typst _data.typ file
2
- import { mm_to_typst } from './utils.js';
2
+ import { mm_to_typst, text_dir } from './utils.js';
3
3
  import { escape_typst_str } from 'typst-utils';
4
4
  import { SCHEMA_DEFAULTS } from './defaults.js';
5
5
  /** Helper: emit a Typst #let binding for a millimetre value */
@@ -30,6 +30,10 @@ function color(name, hsl_str) {
30
30
  function content_raw(name, value) {
31
31
  return `#let ${name} = [${value}]`;
32
32
  }
33
+ /** Helper: emit a Typst #let binding for a text's base direction (ltr/rtl) */
34
+ function dir(name, text) {
35
+ return `#let ${name} = ${text_dir(text)}`;
36
+ }
33
37
  /** Helper: emit a Typst #let binding for a number */
34
38
  function num(name, value) {
35
39
  return `#let ${name} = ${value}`;
@@ -90,6 +94,19 @@ export function build_data_file(schema, dims, colors, font_body_family, font_tit
90
94
  lines.push(bool('has_author', !!(schema.author)));
91
95
  lines.push(bool('has_blurb', !!(schema.blurb)));
92
96
  lines.push('');
97
+ // Base text direction per field (first-strong rule), so RTL text gets RTL punctuation and
98
+ // number placement. The blurb sets its own per paragraph (see form_schema.ts build_blurb).
99
+ // The spine's title + author pair is laid out in the title's direction (author if no title).
100
+ lines.push('// Text direction');
101
+ lines.push(dir('title1_dir', schema.title1 ?? ''));
102
+ lines.push(dir('title2_dir', schema.title2 ?? ''));
103
+ lines.push(dir('title3_dir', schema.title3 ?? ''));
104
+ lines.push(`#let subtitle_dirs = (${subtitle_lines.map(l => `${text_dir(l)}, `).join('')})`);
105
+ lines.push(dir('author_dir', schema.author ?? ''));
106
+ lines.push(dir('spine_title_dir', schema.spine_title ?? ''));
107
+ lines.push(dir('spine_author_dir', schema.spine_author ?? ''));
108
+ lines.push(dir('spine_dir', schema.spine_title || schema.spine_author || ''));
109
+ lines.push('');
93
110
  // Per-text sizing (relative multipliers, default 1.0)
94
111
  lines.push('// Per-text sizing');
95
112
  lines.push(num('title1_size', schema.title1_size ?? SCHEMA_DEFAULTS.title1_size));
@@ -154,12 +171,13 @@ export function build_data_file(schema, dims, colors, font_body_family, font_tit
154
171
  lines.push(str('title_position', schema.title_position));
155
172
  lines.push(str('subtitle_position', schema.subtitle_position));
156
173
  lines.push(str('author_position', schema.author_position));
157
- // Alignment emitted as raw Typst values (left/center/right)
174
+ // Alignment emitted as raw Typst values (left/center/right). Justified text's last line sits
175
+ // at the logical start, which Typst resolves per paragraph — left for LTR, right for RTL
158
176
  lines.push(`#let title_alignment = ${schema.title_alignment ?? SCHEMA_DEFAULTS.title_alignment}`);
159
177
  lines.push(`#let subtitle_alignment = ${schema.subtitle_alignment ?? SCHEMA_DEFAULTS.subtitle_alignment}`);
160
178
  lines.push(`#let author_alignment = ${schema.author_alignment ?? SCHEMA_DEFAULTS.author_alignment}`);
161
179
  const blurb_align = schema.blurb_alignment ?? SCHEMA_DEFAULTS.blurb_alignment;
162
- lines.push(`#let blurb_alignment = ${blurb_align === 'justified' ? 'left' : blurb_align}`);
180
+ lines.push(`#let blurb_alignment = ${blurb_align === 'justified' ? 'start' : blurb_align}`);
163
181
  lines.push(bool('blurb_justify', blurb_align === 'justified'));
164
182
  lines.push('');
165
183
  // Panel margins (all 4 sides of each panel, resolved to mm)
@@ -7,7 +7,7 @@ export declare const SCHEMA_VERSION = 1;
7
7
  * the bundled pattern/vector/icon data. Hosts that re-render a frozen record later should
8
8
  * record this at freeze time (alongside the typst version their wrapper reports) so a drifted
9
9
  * reprint is detectable instead of silent. */
10
- export declare const RENDER_VERSION = 2;
10
+ export declare const RENDER_VERSION = 3;
11
11
  /** What an omitted CoverSchema field resolves to at render time. Derived fields are absent by
12
12
  * design — see the note at the top of this file. */
13
13
  export declare const SCHEMA_DEFAULTS: {
package/dist/defaults.js CHANGED
@@ -19,7 +19,7 @@ export const SCHEMA_VERSION = 1;
19
19
  * the bundled pattern/vector/icon data. Hosts that re-render a frozen record later should
20
20
  * record this at freeze time (alongside the typst version their wrapper reports) so a drifted
21
21
  * reprint is detectable instead of silent. */
22
- export const RENDER_VERSION = 2;
22
+ export const RENDER_VERSION = 3;
23
23
  /** What an omitted CoverSchema field resolves to at render time. Derived fields are absent by
24
24
  * design — see the note at the top of this file. */
25
25
  export const SCHEMA_DEFAULTS = {
@@ -2,7 +2,7 @@
2
2
  import { pm_to_typst } from 'pm-to-typst';
3
3
  import { hex_override_to_hsl } from './colors.js';
4
4
  import { find_pattern } from './patterns.js';
5
- import { warn_unknown } from './utils.js';
5
+ import { warn_unknown, text_dir } from './utils.js';
6
6
  /** Replace straight quotes with typographic curly quotes */
7
7
  export function curly_quotes(text) {
8
8
  return text
@@ -15,14 +15,28 @@ export function curly_quotes(text) {
15
15
  // Remaining single quotes become closing (also covers apostrophes)
16
16
  .replace(/'/g, '’');
17
17
  }
18
+ // Collect the raw text of a ProseMirror node and its descendants
19
+ function pm_text(node) {
20
+ return node.type === 'text' ? node.text ?? '' : (node.content ?? []).map(pm_text).join('');
21
+ }
18
22
  /**
19
23
  * Render the blurb document to Typst markup. pm_to_typst escapes the text and the blurb is
20
24
  * emitted as a Typst content block (see data_file.ts), so Typst's own smartquote curls the
21
25
  * quotes — no manual curling needed here, unlike the plain-string title fields below.
26
+ * Each top-level block (paragraph, heading, whole list, quote) takes its base direction from
27
+ * its own first strong letter, so an RTL paragraph gets RTL punctuation, list markers and
28
+ * justified last line while its LTR neighbours are untouched. Blocks nested inside a list or
29
+ * quote follow the enclosing block's direction.
22
30
  * Returns undefined when the blurb is empty.
23
31
  */
24
32
  function build_blurb(doc) {
25
- const typst = pm_to_typst(doc);
33
+ const typst = pm_to_typst(doc, { nodes: {
34
+ doc: (node, ctx) => (node.content ?? []).map(child => {
35
+ const rendered = ctx.node(child);
36
+ return text_dir(pm_text(child)) === 'rtl'
37
+ ? `#[#set text(dir: rtl)\n${rendered}\n]` : rendered;
38
+ }).join('\n\n'),
39
+ } });
26
40
  return typst.trim() ? typst : undefined;
27
41
  }
28
42
  /** Parse a font family name from user input. Accepts:
@@ -2,7 +2,7 @@
2
2
  // Edit the source .typ files instead, then rerun the generator build
3
3
  /** The cover.typ / _helpers.typ templates baked in at this package version */
4
4
  export const DEFAULT_TEMPLATES = {
5
- cover: "\n// Book cover template — receives all variables from _data.typ\n#import \"_data.typ\": *\n\n// -- Page setup --\n#set page(\n width: total_width,\n height: total_height,\n margin: 0mm,\n)\n\n#set text(font: font_body_family)\n#set quote(block: true)\n#show quote: set pad(x: 3em)\n\n// ============================================================\n// Helper functions\n// ============================================================\n\n#import \"_helpers.typ\": *\n\n\n// ============================================================\n// Layer 1 — Background fills\n// ============================================================\n\n#place(\n top + left,\n rect(\n width: total_width,\n height: total_height,\n fill: if has_front_gradient {\n gradient.linear(\n (color_front_gradient_start, 0%),\n (color_front_bg, 30%),\n (color_front_bg, 45%),\n (color_front_gradient_end, 100%),\n angle: -40deg,\n )\n } else {\n color_front_bg\n },\n ),\n)\n\n\n#if has_pattern {\n let tiled = pattern(\n size: (pattern_tile_w, pattern_tile_h),\n image(\"pattern.svg\", width: pattern_tile_w, height: pattern_tile_h),\n )\n place(\n top + left,\n rect(width: total_width, height: total_height, fill: tiled),\n )\n}\n\n\n// ============================================================\n// Layer 2 — Background image\n// ============================================================\n\n#if has_image {\n if image_coverage == \"full\" {\n // Full spread: image covers entire canvas (back + spine + front)\n place(\n top + left,\n dx: 0mm,\n dy: 0mm,\n image(\n image_filename,\n width: total_width,\n height: total_height,\n fit: if image_is_vector { \"stretch\" } else { \"cover\" },\n ),\n )\n } else if image_coverage == \"painted\" {\n // Painted: image inset on front cover — 15mm margin horizontally and from bottom, 50% trim height\n let painted_w = face_width - 30mm\n let painted_h = face_height * 0.5 // sync: index.ts painted_h = face_height * 0.5\n place(\n top + left,\n dx: front_x + 15mm,\n dy: bleed + face_height * (1/3),\n image(\n image_filename,\n width: painted_w,\n height: painted_h,\n fit: \"stretch\", // Should be exact but just in case, ensures full frame visible\n ),\n )\n } else if image_coverage == \"feature\" {\n // Feature: same size and position as painted but with rounded corners via box clip\n let painted_w = face_width - 30mm\n let painted_h = face_height * 0.5\n place(\n top + left,\n dx: front_x + 15mm,\n dy: bleed + face_height * (1/3),\n box(\n width: painted_w,\n height: painted_h,\n radius: 6mm,\n clip: true,\n image(\n image_filename,\n width: painted_w,\n height: painted_h,\n fit: \"cover\",\n ),\n ),\n )\n } else if image_coverage == \"front_partial\" {\n // Lower 2/3: image covers the full front face width, bottom 2/3 of height, flush to the bottom trim edge\n let w = face_width\n let h = face_height * (2 / 3)\n place(\n top + left,\n dx: front_x,\n dy: bleed + face_height - h,\n image(\n image_filename,\n width: w,\n height: h,\n fit: \"cover\",\n ),\n )\n } else {\n // Front only: image covers the front panel including all bleed margins\n place(\n top + left,\n dx: front_x,\n dy: 0mm,\n image(\n image_filename,\n width: face_width + bleed,\n height: total_height,\n fit: if image_is_vector { \"stretch\" } else { \"cover\" },\n ),\n )\n }\n}\n\n\n// ============================================================\n// Layer 2b — Icon on front cover\n// ============================================================\n\n#if has_icon {\n let icon_size = calc.min(face_height * 0.4, face_width * 0.8)\n if icon_mode != \"center\" {\n icon_size = calc.min(face_height * 0.5, face_width * 0.8)\n }\n // Apply user-controlled size multiplier (1.0 = default)\n icon_size = icon_size * icon_size_mod\n let offset_icon_dx = front_x + (face_width - icon_size) + (icon_size * 0.15)\n let offset_icon_dy = bleed * -1 + (icon_size * 0.15)\n\n // Place ghost icons first (if any) so below main one\n if icon_mode == \"echo\" {\n place(\n bottom + left,\n dx: offset_icon_dx + icon_size * 0.1,\n dy: offset_icon_dy - icon_size * 0.15,\n image(\"icon_ghost.svg\", width: icon_size, height: icon_size),\n )\n place(\n bottom + left,\n dx: offset_icon_dx - icon_size * 0.1,\n dy: offset_icon_dy + icon_size * 0.1,\n image(\"icon_ghost2.svg\", width: icon_size, height: icon_size),\n )\n }\n\n if icon_mode == \"center\" {\n // Center — large icon centered horizontally, positioned in the lower/mid section\n place(\n bottom + left,\n dx: front_x + (face_width - icon_size) / 2,\n dy: (face_height * 0.2 + bleed) * -1,\n image(\"icon_main.svg\", width: icon_size, height: icon_size),\n )\n } else {\n // Offset/echo: large icon offset towards bottom-right, slightly overflowing the trim edge\n place(\n bottom + left,\n dx: offset_icon_dx,\n dy: offset_icon_dy,\n image(\"icon_main.svg\", width: icon_size, height: icon_size),\n )\n }\n}\n\n\n// ============================================================\n// Layer 2c — Spine background\n// ============================================================\n\n// Spine background (only when spread has a spine) — drawn after pattern so it covers it\n#if has_spine and color_spine_bg != none {\n place(\n top + left,\n dx: spine_x,\n dy: 0mm,\n rect(width: spine_width, height: total_height, fill: color_spine_bg),\n )\n}\n\n\n// ============================================================\n// Layer 3 — Back panel content\n// ============================================================\n\n// back_margin, back_content_w, spiral_margin imported from _data.typ\n\n// Blurb container box with background color and 1cm padding around the text\n// Content is scaled down if it would overflow the available back-panel height\n#if has_blurb {\n let blurb_content = {\n set par(justify: true) if blurb_justify\n set par(leading: 1em * blurb_spacing)\n // Compact heading styles for a small blurb box — size and leading are hardcoded\n // independent of blurb_spacing, since headings rarely wrap and need tight leading\n show heading.where(level: 1): it => {\n set block(above: 2em, below: 1em)\n set text(size: 1.3em)\n set par(leading: 0.5em)\n it\n }\n show heading.where(level: 2): it => {\n set block(above: 2em, below: 1em)\n set text(size: 1.15em)\n set par(leading: 0.5em)\n it\n }\n align(blurb_alignment, text(\n font: font_blurb_family,\n size: fs_back_blurb * blurb_size,\n fill: color_blurb,\n blurb,\n ))\n }\n let blurb_max_h = back_content_h - blurb_padding * 2\n place(\n top + left,\n // Centered within the safe area, not the full face — spiral_margin is 0 for other bindings\n dx: back_x + (face_width - spiral_margin - blurb_width) / 2,\n dy: bleed + back_margin,\n rect(\n width: blurb_width,\n fill: color_blurb_bg,\n stroke: none,\n radius: 1.5mm,\n inset: blurb_padding,\n context {\n let avail_w = blurb_width - blurb_padding * 2\n let natural = measure(blurb_content, width: avail_w)\n if natural.height > blurb_max_h {\n let ratio = blurb_max_h / natural.height\n scale(x: ratio * 100%, y: ratio * 100%, reflow: true, blurb_content)\n } else {\n blurb_content\n }\n },\n ),\n )\n}\n\n// ISBN barcode — position and size from printing-services cover_region_barcode\n#if has_barcode {\n place(\n bottom + left,\n dx: barcode_x,\n dy: barcode_y * -1,\n image(\"barcode.svg\", width: barcode_w, height: barcode_h),\n )\n}\n\n// ============================================================\n// Layer 4 — Spine content\n// ============================================================\n\n#if has_spine and has_spine_text and fs_spine_title > 0mm {\n // Title rotated along spine (bottom-to-top reading direction).\n // place() anchors the unrotated bounding box top-left, so we subtract\n // half the unrotated dimensions to keep the visual center on the spine.\n place(\n top + left,\n dx: spine_x + spine_width * 0.5 - face_height * 0.4,\n dy: bleed + face_height * 0.5 - spine_width * 0.5,\n rotate(\n 90deg,\n origin: center,\n box(\n width: face_height * 0.8,\n height: spine_width,\n align(\n center + horizon,\n // Shrink title + author together as one unit if they overflow the spine length\n shrink-to-width(\n stack(\n dir: ltr,\n spacing: spine_width * 0.3,\n text(\n font: font_spine_title_family,\n size: fs_spine_title * spine_title_size_mod,\n fill: color_spine_title,\n weight: spine_title_weight,\n style: if spine_title_italic { \"italic\" } else { \"normal\" },\n spine_title,\n ),\n if has_author and fs_spine_author > 0mm {\n text(\n font: font_spine_author_family,\n size: fs_spine_author * spine_author_size_mod,\n fill: color_spine_author,\n weight: spine_author_weight,\n style: if spine_author_italic { \"italic\" } else { \"normal\" },\n spine_author,\n )\n },\n ),\n ),\n ),\n ),\n ),\n )\n}\n\n// Small icon at the bottom of the spine (when spine is wide enough for text)\n#if has_spine_icon {\n let spine_icon_size = spine_width * 0.7\n let spine_icon_side_margin = (spine_width - spine_icon_size) / 2\n // Bottom margin is twice the side margin\n let spine_icon_bottom_margin = spine_icon_side_margin * 2\n place(\n top + left,\n dx: spine_x + spine_icon_side_margin,\n dy: bleed + face_height - spine_icon_size - spine_icon_bottom_margin,\n image(\"icon_spine.svg\", width: spine_icon_size, height: spine_icon_size),\n )\n}\n\n// ============================================================\n// Layer 5 — Front panel content\n// ============================================================\n\n// front_margin, front_content_w, spiral_margin imported from _data.typ\n\n// Base font size — proportional to trim height, multiplied by per-title size modifier\n#let fs_title_base = face_height * 0.05\n#let fs_subtitle = face_height * 0.025 * subtitle_size\n#let fs_author = face_height * 0.035 * author_size\n\n// Render a single title line with its per-title styling, shrunk to fit if too wide\n#let render_title(txt, font_family, size_mod, weight, is_italic, color) = {\n if txt != \"\" {\n box(\n width: front_content_w,\n shrink-to-width(\n align(title_alignment, text(\n font: font_family,\n size: fs_title_base * size_mod,\n fill: color,\n weight: weight,\n style: if is_italic { \"italic\" } else { \"normal\" },\n txt,\n )),\n ),\n )\n }\n}\n\n// Render each title line, then filter out empty ones so they don't create stack spacing gaps\n#let title_lines_rendered = stack(\n dir: ttb,\n spacing: face_height * title_spacing / 100,\n ..(\n render_title(title1, font_title1_family, title1_size, title1_weight, title1_italic, color_front_title1),\n render_title(title2, font_title2_family, title2_size, title2_weight, title2_italic, color_front_title2),\n render_title(title3, font_title3_family, title3_size, title3_weight, title3_italic, color_front_title3),\n ).filter(x => x != none),\n)\n\n// All subtitle lines in one container — shrink-to-width scales them all together uniformly\n// text() wraps shrink-to-width so inner content inherits the scaled size (no explicit size inside)\n#let subtitle_lines_rendered = text(\n font: font_subtitle_family,\n size: fs_subtitle,\n weight: subtitle_weight,\n style: if subtitle_italic { \"italic\" } else { \"normal\" },\n fill: color_front_subtitle,\n box(\n width: front_content_w,\n shrink-to-width(\n stack(\n dir: ttb,\n spacing: face_height * subtitle_spacing / 100,\n ..subtitle_lines.map(line => align(subtitle_alignment, line)),\n )\n ),\n ),\n)\n\n// Author styled text — single line, shrunk to fit width\n#let author_rendered = box(\n width: front_content_w,\n shrink-to-width(\n align(author_alignment, text(\n font: font_author_family,\n size: fs_author,\n fill: color_front_author,\n weight: author_weight,\n style: if author_italic { \"italic\" } else { \"normal\" },\n author,\n )),\n ),\n)\n\n// Title group: stacked title lines\n#let title_group = stack(\n dir: ttb,\n spacing: face_height * title_spacing / 100,\n title_lines_rendered,\n)\n\n// Check which elements belong to each position box\n#let top_has_title = title_position == \"top\"\n#let top_has_subtitle = has_subtitle and subtitle_position == \"top\"\n#let top_has_author = has_author and author_position == \"top\"\n#let top_has_any = top_has_title or top_has_subtitle or top_has_author\n\n#let mid_has_title = title_position == \"middle\"\n#let mid_has_subtitle = has_subtitle and subtitle_position == \"middle\"\n#let mid_has_author = has_author and author_position == \"middle\"\n#let mid_has_any = mid_has_title or mid_has_subtitle or mid_has_author\n\n#let bot_has_title = title_position == \"bottom\"\n#let bot_has_subtitle = has_subtitle and subtitle_position == \"bottom\"\n#let bot_has_author = has_author and author_position == \"bottom\"\n#let bot_has_any = bot_has_title or bot_has_subtitle or bot_has_author\n\n// Build a content group for a position box (order within box: title | subtitle | author)\n// Each element gets its own vertical margins inside the box\n#let make_pos_box(has_t, has_s, has_a) = box(\n width: front_content_w,\n stack(\n dir: ttb,\n if has_t { v(face_height * title_margin_top / 100) },\n if has_t { title_group },\n if has_t { v(face_height * title_margin_bottom / 100) },\n if has_s { v(face_height * subtitle_margin_top / 100) },\n if has_s { subtitle_lines_rendered },\n if has_s { v(face_height * subtitle_margin_bottom / 100) },\n if has_a { v(face_height * author_margin_top / 100) },\n if has_a { author_rendered },\n if has_a { v(face_height * author_margin_bottom / 100) },\n ),\n)\n\n// Place each non-empty position box on the front panel (within panel margin on all sides)\n#if top_has_any {\n place(\n top + left,\n dx: front_x + front_margin + spiral_margin,\n dy: bleed + front_margin,\n make_pos_box(top_has_title, top_has_subtitle, top_has_author),\n )\n}\n#if mid_has_any {\n place(\n horizon + left,\n dx: front_x + front_margin + spiral_margin,\n make_pos_box(mid_has_title, mid_has_subtitle, mid_has_author),\n )\n}\n#if bot_has_any {\n place(\n bottom + left,\n dx: front_x + front_margin + spiral_margin,\n dy: (bleed + front_margin) * -1,\n make_pos_box(bot_has_title, bot_has_subtitle, bot_has_author),\n )\n}\n\n\n// ============================================================\n// Layer 6 — Home-print margin (white rounded borders for home inkjet printers)\n// ============================================================\n\n#if home_print {\n // Render the back and front as two separate rounded cards, each with a white\n // border, so a home printer that can't reach the paper edge still prints\n // cleanly. One white matte covers the spread with two rounded windows punched\n // out — one per trim face, each inset by `home_margin` on all sides with\n // `home_corner`-rounded corners. Windows track the trim faces (not a spine-\n // centre split), so the bleed and spine become white gutter and every side —\n // including the inner/gutter edge — gets a real margin. A single matte (vs.\n // two abutting ones) avoids a hairline seam down the gutter.\n let m = home_margin\n let win_w = face_width - 2 * m\n let win_h = face_height - 2 * m\n let win_y = bleed + m\n let back_win = (bleed + m, win_y, win_w, win_h)\n let front_win = (total_width - bleed - face_width + m, win_y, win_w, win_h)\n home-print-matte(total_width, total_height, (back_win, front_win), home_corner)\n}\n",
5
+ cover: "\n// Book cover template — receives all variables from _data.typ\n#import \"_data.typ\": *\n\n// -- Page setup --\n#set page(\n width: total_width,\n height: total_height,\n margin: 0mm,\n)\n\n#set text(font: font_body_family)\n#set quote(block: true)\n#show quote: set pad(x: 3em)\n\n// ============================================================\n// Helper functions\n// ============================================================\n\n#import \"_helpers.typ\": *\n\n\n// ============================================================\n// Layer 1 — Background fills\n// ============================================================\n\n#place(\n top + left,\n rect(\n width: total_width,\n height: total_height,\n fill: if has_front_gradient {\n gradient.linear(\n (color_front_gradient_start, 0%),\n (color_front_bg, 30%),\n (color_front_bg, 45%),\n (color_front_gradient_end, 100%),\n angle: -40deg,\n )\n } else {\n color_front_bg\n },\n ),\n)\n\n\n#if has_pattern {\n let tiled = pattern(\n size: (pattern_tile_w, pattern_tile_h),\n image(\"pattern.svg\", width: pattern_tile_w, height: pattern_tile_h),\n )\n place(\n top + left,\n rect(width: total_width, height: total_height, fill: tiled),\n )\n}\n\n\n// ============================================================\n// Layer 2 — Background image\n// ============================================================\n\n#if has_image {\n if image_coverage == \"full\" {\n // Full spread: image covers entire canvas (back + spine + front)\n place(\n top + left,\n dx: 0mm,\n dy: 0mm,\n image(\n image_filename,\n width: total_width,\n height: total_height,\n fit: if image_is_vector { \"stretch\" } else { \"cover\" },\n ),\n )\n } else if image_coverage == \"painted\" {\n // Painted: image inset on front cover — 15mm margin horizontally and from bottom, 50% trim height\n let painted_w = face_width - 30mm\n let painted_h = face_height * 0.5 // sync: index.ts painted_h = face_height * 0.5\n place(\n top + left,\n dx: front_x + 15mm,\n dy: bleed + face_height * (1/3),\n image(\n image_filename,\n width: painted_w,\n height: painted_h,\n fit: \"stretch\", // Should be exact but just in case, ensures full frame visible\n ),\n )\n } else if image_coverage == \"feature\" {\n // Feature: same size and position as painted but with rounded corners via box clip\n let painted_w = face_width - 30mm\n let painted_h = face_height * 0.5\n place(\n top + left,\n dx: front_x + 15mm,\n dy: bleed + face_height * (1/3),\n box(\n width: painted_w,\n height: painted_h,\n radius: 6mm,\n clip: true,\n image(\n image_filename,\n width: painted_w,\n height: painted_h,\n fit: \"cover\",\n ),\n ),\n )\n } else if image_coverage == \"front_partial\" {\n // Lower 2/3: image covers the full front face width, bottom 2/3 of height, flush to the bottom trim edge\n let w = face_width\n let h = face_height * (2 / 3)\n place(\n top + left,\n dx: front_x,\n dy: bleed + face_height - h,\n image(\n image_filename,\n width: w,\n height: h,\n fit: \"cover\",\n ),\n )\n } else {\n // Front only: image covers the front panel including all bleed margins\n place(\n top + left,\n dx: front_x,\n dy: 0mm,\n image(\n image_filename,\n width: face_width + bleed,\n height: total_height,\n fit: if image_is_vector { \"stretch\" } else { \"cover\" },\n ),\n )\n }\n}\n\n\n// ============================================================\n// Layer 2b — Icon on front cover\n// ============================================================\n\n#if has_icon {\n let icon_size = calc.min(face_height * 0.4, face_width * 0.8)\n if icon_mode != \"center\" {\n icon_size = calc.min(face_height * 0.5, face_width * 0.8)\n }\n // Apply user-controlled size multiplier (1.0 = default)\n icon_size = icon_size * icon_size_mod\n let offset_icon_dx = front_x + (face_width - icon_size) + (icon_size * 0.15)\n let offset_icon_dy = bleed * -1 + (icon_size * 0.15)\n\n // Place ghost icons first (if any) so below main one\n if icon_mode == \"echo\" {\n place(\n bottom + left,\n dx: offset_icon_dx + icon_size * 0.1,\n dy: offset_icon_dy - icon_size * 0.15,\n image(\"icon_ghost.svg\", width: icon_size, height: icon_size),\n )\n place(\n bottom + left,\n dx: offset_icon_dx - icon_size * 0.1,\n dy: offset_icon_dy + icon_size * 0.1,\n image(\"icon_ghost2.svg\", width: icon_size, height: icon_size),\n )\n }\n\n if icon_mode == \"center\" {\n // Center — large icon centered horizontally, positioned in the lower/mid section\n place(\n bottom + left,\n dx: front_x + (face_width - icon_size) / 2,\n dy: (face_height * 0.2 + bleed) * -1,\n image(\"icon_main.svg\", width: icon_size, height: icon_size),\n )\n } else {\n // Offset/echo: large icon offset towards bottom-right, slightly overflowing the trim edge\n place(\n bottom + left,\n dx: offset_icon_dx,\n dy: offset_icon_dy,\n image(\"icon_main.svg\", width: icon_size, height: icon_size),\n )\n }\n}\n\n\n// ============================================================\n// Layer 2c — Spine background\n// ============================================================\n\n// Spine background (only when spread has a spine) — drawn after pattern so it covers it\n#if has_spine and color_spine_bg != none {\n place(\n top + left,\n dx: spine_x,\n dy: 0mm,\n rect(width: spine_width, height: total_height, fill: color_spine_bg),\n )\n}\n\n\n// ============================================================\n// Layer 3 — Back panel content\n// ============================================================\n\n// back_margin, back_content_w, spiral_margin imported from _data.typ\n\n// Blurb container box with background color and 1cm padding around the text\n// Content is scaled down if it would overflow the available back-panel height\n#if has_blurb {\n let blurb_content = {\n set par(justify: true) if blurb_justify\n set par(leading: 1em * blurb_spacing)\n // Compact heading styles for a small blurb box — size and leading are hardcoded\n // independent of blurb_spacing, since headings rarely wrap and need tight leading\n show heading.where(level: 1): it => {\n set block(above: 2em, below: 1em)\n set text(size: 1.3em)\n set par(leading: 0.5em)\n it\n }\n show heading.where(level: 2): it => {\n set block(above: 2em, below: 1em)\n set text(size: 1.15em)\n set par(leading: 0.5em)\n it\n }\n align(blurb_alignment, text(\n font: font_blurb_family,\n size: fs_back_blurb * blurb_size,\n fill: color_blurb,\n blurb,\n ))\n }\n let blurb_max_h = back_content_h - blurb_padding * 2\n place(\n top + left,\n // Centered within the safe area, not the full face — spiral_margin is 0 for other bindings\n dx: back_x + (face_width - spiral_margin - blurb_width) / 2,\n dy: bleed + back_margin,\n rect(\n width: blurb_width,\n fill: color_blurb_bg,\n stroke: none,\n radius: 1.5mm,\n inset: blurb_padding,\n context {\n let avail_w = blurb_width - blurb_padding * 2\n let natural = measure(blurb_content, width: avail_w)\n if natural.height > blurb_max_h {\n let ratio = blurb_max_h / natural.height\n scale(x: ratio * 100%, y: ratio * 100%, reflow: true, blurb_content)\n } else {\n blurb_content\n }\n },\n ),\n )\n}\n\n// ISBN barcode — position and size from printing-services cover_region_barcode\n#if has_barcode {\n place(\n bottom + left,\n dx: barcode_x,\n dy: barcode_y * -1,\n image(\"barcode.svg\", width: barcode_w, height: barcode_h),\n )\n}\n\n// ============================================================\n// Layer 4 — Spine content\n// ============================================================\n\n#if has_spine and has_spine_text and fs_spine_title > 0mm {\n // Title rotated along spine (bottom-to-top reading direction).\n // place() anchors the unrotated bounding box top-left, so we subtract\n // half the unrotated dimensions to keep the visual center on the spine.\n // The title + author pair runs in the title's direction, so for RTL the title\n // comes first reading right-to-left (bottom-to-top once rotated).\n place(\n top + left,\n dx: spine_x + spine_width * 0.5 - face_height * 0.4,\n dy: bleed + face_height * 0.5 - spine_width * 0.5,\n rotate(\n 90deg,\n origin: center,\n box(\n width: face_height * 0.8,\n height: spine_width,\n align(\n center + horizon,\n // Shrink title + author together as one unit if they overflow the spine length\n shrink-to-width(\n stack(\n dir: spine_dir,\n spacing: spine_width * 0.3,\n text(\n dir: spine_title_dir,\n font: font_spine_title_family,\n size: fs_spine_title * spine_title_size_mod,\n fill: color_spine_title,\n weight: spine_title_weight,\n style: if spine_title_italic { \"italic\" } else { \"normal\" },\n spine_title,\n ),\n if has_author and fs_spine_author > 0mm {\n text(\n dir: spine_author_dir,\n font: font_spine_author_family,\n size: fs_spine_author * spine_author_size_mod,\n fill: color_spine_author,\n weight: spine_author_weight,\n style: if spine_author_italic { \"italic\" } else { \"normal\" },\n spine_author,\n )\n },\n ),\n ),\n ),\n ),\n ),\n )\n}\n\n// Small icon at the bottom of the spine (when spine is wide enough for text)\n#if has_spine_icon {\n let spine_icon_size = spine_width * 0.7\n let spine_icon_side_margin = (spine_width - spine_icon_size) / 2\n // Bottom margin is twice the side margin\n let spine_icon_bottom_margin = spine_icon_side_margin * 2\n place(\n top + left,\n dx: spine_x + spine_icon_side_margin,\n dy: bleed + face_height - spine_icon_size - spine_icon_bottom_margin,\n image(\"icon_spine.svg\", width: spine_icon_size, height: spine_icon_size),\n )\n}\n\n// ============================================================\n// Layer 5 — Front panel content\n// ============================================================\n\n// front_margin, front_content_w, spiral_margin imported from _data.typ\n\n// Base font size — proportional to trim height, multiplied by per-title size modifier\n#let fs_title_base = face_height * 0.05\n#let fs_subtitle = face_height * 0.025 * subtitle_size\n#let fs_author = face_height * 0.035 * author_size\n\n// Render a single title line with its per-title styling, shrunk to fit if too wide\n#let render_title(txt, font_family, size_mod, weight, is_italic, color, txt_dir) = {\n if txt != \"\" {\n box(\n width: front_content_w,\n shrink-to-width(\n align(title_alignment, text(\n dir: txt_dir,\n font: font_family,\n size: fs_title_base * size_mod,\n fill: color,\n weight: weight,\n style: if is_italic { \"italic\" } else { \"normal\" },\n txt,\n )),\n ),\n )\n }\n}\n\n// Render each title line, then filter out empty ones so they don't create stack spacing gaps\n#let title_lines_rendered = stack(\n dir: ttb,\n spacing: face_height * title_spacing / 100,\n ..(\n render_title(title1, font_title1_family, title1_size, title1_weight, title1_italic, color_front_title1, title1_dir),\n render_title(title2, font_title2_family, title2_size, title2_weight, title2_italic, color_front_title2, title2_dir),\n render_title(title3, font_title3_family, title3_size, title3_weight, title3_italic, color_front_title3, title3_dir),\n ).filter(x => x != none),\n)\n\n// All subtitle lines in one container — shrink-to-width scales them all together uniformly\n// text() wraps shrink-to-width so inner content inherits the scaled size (no explicit size inside)\n#let subtitle_lines_rendered = text(\n font: font_subtitle_family,\n size: fs_subtitle,\n weight: subtitle_weight,\n style: if subtitle_italic { \"italic\" } else { \"normal\" },\n fill: color_front_subtitle,\n box(\n width: front_content_w,\n shrink-to-width(\n stack(\n dir: ttb,\n spacing: face_height * subtitle_spacing / 100,\n ..subtitle_lines.zip(subtitle_dirs).map(((line, line_dir)) => align(\n subtitle_alignment, text(dir: line_dir, line),\n )),\n )\n ),\n ),\n)\n\n// Author styled text — single line, shrunk to fit width\n#let author_rendered = box(\n width: front_content_w,\n shrink-to-width(\n align(author_alignment, text(\n dir: author_dir,\n font: font_author_family,\n size: fs_author,\n fill: color_front_author,\n weight: author_weight,\n style: if author_italic { \"italic\" } else { \"normal\" },\n author,\n )),\n ),\n)\n\n// Title group: stacked title lines\n#let title_group = stack(\n dir: ttb,\n spacing: face_height * title_spacing / 100,\n title_lines_rendered,\n)\n\n// Check which elements belong to each position box\n#let top_has_title = title_position == \"top\"\n#let top_has_subtitle = has_subtitle and subtitle_position == \"top\"\n#let top_has_author = has_author and author_position == \"top\"\n#let top_has_any = top_has_title or top_has_subtitle or top_has_author\n\n#let mid_has_title = title_position == \"middle\"\n#let mid_has_subtitle = has_subtitle and subtitle_position == \"middle\"\n#let mid_has_author = has_author and author_position == \"middle\"\n#let mid_has_any = mid_has_title or mid_has_subtitle or mid_has_author\n\n#let bot_has_title = title_position == \"bottom\"\n#let bot_has_subtitle = has_subtitle and subtitle_position == \"bottom\"\n#let bot_has_author = has_author and author_position == \"bottom\"\n#let bot_has_any = bot_has_title or bot_has_subtitle or bot_has_author\n\n// Build a content group for a position box (order within box: title | subtitle | author)\n// Each element gets its own vertical margins inside the box\n#let make_pos_box(has_t, has_s, has_a) = box(\n width: front_content_w,\n stack(\n dir: ttb,\n if has_t { v(face_height * title_margin_top / 100) },\n if has_t { title_group },\n if has_t { v(face_height * title_margin_bottom / 100) },\n if has_s { v(face_height * subtitle_margin_top / 100) },\n if has_s { subtitle_lines_rendered },\n if has_s { v(face_height * subtitle_margin_bottom / 100) },\n if has_a { v(face_height * author_margin_top / 100) },\n if has_a { author_rendered },\n if has_a { v(face_height * author_margin_bottom / 100) },\n ),\n)\n\n// Place each non-empty position box on the front panel (within panel margin on all sides)\n#if top_has_any {\n place(\n top + left,\n dx: front_x + front_margin + spiral_margin,\n dy: bleed + front_margin,\n make_pos_box(top_has_title, top_has_subtitle, top_has_author),\n )\n}\n#if mid_has_any {\n place(\n horizon + left,\n dx: front_x + front_margin + spiral_margin,\n make_pos_box(mid_has_title, mid_has_subtitle, mid_has_author),\n )\n}\n#if bot_has_any {\n place(\n bottom + left,\n dx: front_x + front_margin + spiral_margin,\n dy: (bleed + front_margin) * -1,\n make_pos_box(bot_has_title, bot_has_subtitle, bot_has_author),\n )\n}\n\n\n// ============================================================\n// Layer 6 — Home-print margin (white rounded borders for home inkjet printers)\n// ============================================================\n\n#if home_print {\n // Render the back and front as two separate rounded cards, each with a white\n // border, so a home printer that can't reach the paper edge still prints\n // cleanly. One white matte covers the spread with two rounded windows punched\n // out — one per trim face, each inset by `home_margin` on all sides with\n // `home_corner`-rounded corners. Windows track the trim faces (not a spine-\n // centre split), so the bleed and spine become white gutter and every side —\n // including the inner/gutter edge — gets a real margin. A single matte (vs.\n // two abutting ones) avoids a hairline seam down the gutter.\n let m = home_margin\n let win_w = face_width - 2 * m\n let win_h = face_height - 2 * m\n let win_y = bleed + m\n let back_win = (bleed + m, win_y, win_w, win_h)\n let front_win = (total_width - bleed - face_width + m, win_y, win_w, win_h)\n home-print-matte(total_width, total_height, (back_win, front_win), home_corner)\n}\n",
6
6
  helpers: "\n// Shared helper functions for the cover template\n\n// Scale content so it fits within the container width using geometric scaling.\n// Uses scale() rather than text(size: ...) so inner explicit font sizes are also scaled.\n#let fit-to-width(max-text-size: auto, min-text-size: 2mm, it) = context {\n let effective-max = if max-text-size == auto { text.size * 3 } else { max-text-size }\n let effective-min = if min-text-size == auto { text.size * 0.33 } else { min-text-size }\n // Express size limits as dimensionless ratios relative to current text size\n let max-ratio = effective-max / text.size\n let min-ratio = effective-min / text.size\n let contentsize = measure(it)\n layout(size => {\n if contentsize.width > 0mm {\n let ratio = calc.min(size.width / contentsize.width, size.height / contentsize.height)\n let clamped = calc.max(calc.min(ratio, max-ratio), min-ratio)\n if clamped != 1.0 {\n scale(x: clamped * 100%, y: clamped * 100%, reflow: true, it)\n } else {\n it\n }\n }\n })\n}\n\n// Variant that only shrinks (never grows beyond current text size)\n#let shrink-to-width(min-text-size: auto, it) = context {\n fit-to-width(max-text-size: text.size, min-text-size: min-text-size, it)\n}\n\n// Curve segments tracing one rounded rectangle (absolute coords), for compositing\n// into a larger path. Corners use a cubic-bezier handle (0.5523r) to approximate arcs.\n#let rounded-subpath(x, y, w, h, r) = {\n let k = 0.5523 * r\n (\n curve.move((x + r, y), relative: false),\n curve.line((x + w - r, y), relative: false),\n curve.cubic((x + w - r + k, y), (x + w, y + r - k), (x + w, y + r), relative: false),\n curve.line((x + w, y + h - r), relative: false),\n curve.cubic((x + w, y + h - r + k), (x + w - r + k, y + h), (x + w - r, y + h), relative: false),\n curve.line((x + r, y + h), relative: false),\n curve.cubic((x + r - k, y + h), (x, y + h - r + k), (x, y + h - r), relative: false),\n curve.line((x, y + r), relative: false),\n curve.cubic((x, y + r - k), (x + r - k, y), (x + r, y), relative: false),\n curve.close(),\n )\n}\n\n// White \"matte\" covering the whole canvas (cw × ch) with rounded-rect windows\n// punched out via even-odd fill, so the artwork shows through each window as a\n// rounded card surrounded by a white border. Used for home-print margins on\n// edge-to-edge-incapable printers. `windows` is an array of (x, y, w, h) tuples.\n#let home-print-matte(cw, ch, windows, r) = {\n let segs = (\n curve.move((0mm, 0mm), relative: false),\n curve.line((cw, 0mm), relative: false),\n curve.line((cw, ch), relative: false),\n curve.line((0mm, ch), relative: false),\n curve.close(),\n )\n for win in windows {\n segs = segs + rounded-subpath(win.at(0), win.at(1), win.at(2), win.at(3), r)\n }\n place(top + left, curve(fill: white, fill-rule: \"even-odd\", ..segs))\n}\n",
7
7
  };
8
8
  //# sourceMappingURL=templates_data.js.map
package/dist/utils.d.ts CHANGED
@@ -4,6 +4,10 @@ export declare function mm_to_typst(mm: number): string;
4
4
  export declare function clamp(value: number, min: number, max: number): number;
5
5
  /** Derive the default spine title by joining all the title lines */
6
6
  export declare function default_spine_title(title1?: string, title2?: string, title3?: string): string;
7
+ /** A text's base direction by the Unicode first-strong rule (as HTML's dir="auto"): the first
8
+ * letter decides, so a Hebrew sentence quoting an English word is still RTL. Text with no
9
+ * letters at all (digits, punctuation) is LTR. */
10
+ export declare function text_dir(text: string): 'ltr' | 'rtl';
7
11
  /** Report a value naming something this package version doesn't have (a pruned or renamed
8
12
  * pattern, vector background, or background image). The feature is dropped rather than failing
9
13
  * the whole render, but never silently — a missing ID means a stored record can no longer be
package/dist/utils.js CHANGED
@@ -15,6 +15,34 @@ export function default_spine_title(title1, title2, title3) {
15
15
  .trim()
16
16
  .replace(/ +/g, ' ');
17
17
  }
18
+ // Unicode scripts whose letters are strong right-to-left (bidi class R or AL). Built from names
19
+ // so any the JS engine's Unicode database doesn't know yet are skipped rather than throwing
20
+ const RTL_SCRIPTS = [
21
+ 'Hebrew', 'Arabic', 'Syriac', 'Thaana', 'Nko', 'Samaritan', 'Mandaic', 'Adlam',
22
+ 'Hanifi_Rohingya', 'Yezidi', 'Mende_Kikakui', 'Garay', 'Imperial_Aramaic', 'Phoenician',
23
+ 'Kharoshthi', 'Old_South_Arabian', 'Old_North_Arabian', 'Avestan', 'Inscriptional_Parthian',
24
+ 'Inscriptional_Pahlavi', 'Psalter_Pahlavi', 'Old_Turkic', 'Old_Hungarian', 'Nabataean',
25
+ 'Palmyrene', 'Hatran', 'Manichaean', 'Elymaic', 'Sogdian', 'Old_Sogdian', 'Old_Uyghur',
26
+ 'Chorasmian', 'Lydian', 'Meroitic_Cursive', 'Meroitic_Hieroglyphs', 'Cypriot',
27
+ ].filter(script => {
28
+ try {
29
+ new RegExp(`\\p{Script=${script}}`, 'u');
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ });
36
+ const RTL_REGEX = new RegExp(`[${RTL_SCRIPTS.map(s => `\\p{Script=${s}}`).join('')}\\u200F\\u061C]`, 'u');
37
+ // Strong directional characters: letters, plus the LRM/RLM/ALM marks typed to force a direction
38
+ const STRONG_REGEX = /[\p{L}‎‏؜]/u;
39
+ /** A text's base direction by the Unicode first-strong rule (as HTML's dir="auto"): the first
40
+ * letter decides, so a Hebrew sentence quoting an English word is still RTL. Text with no
41
+ * letters at all (digits, punctuation) is LTR. */
42
+ export function text_dir(text) {
43
+ const strong = STRONG_REGEX.exec(text);
44
+ return strong && RTL_REGEX.test(strong[0]) ? 'rtl' : 'ltr';
45
+ }
18
46
  /** Report a value naming something this package version doesn't have (a pruned or renamed
19
47
  * pattern, vector background, or background image). The feature is dropped rather than failing
20
48
  * the whole render, but never silently — a missing ID means a stored record can no longer be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bookcover-core",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "license": "MIT-0",
5
5
  "repository": {
6
6
  "type": "git",