diff --git a/packages/preview/basho/0.1.0/LICENSE b/packages/preview/basho/0.1.0/LICENSE new file mode 100644 index 0000000000..287fdd755f --- /dev/null +++ b/packages/preview/basho/0.1.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 KoyaTofu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/basho/0.1.0/README.md b/packages/preview/basho/0.1.0/README.md new file mode 100644 index 0000000000..85dbb3b332 --- /dev/null +++ b/packages/preview/basho/0.1.0/README.md @@ -0,0 +1,158 @@ +# Basho — Vertical Japanese Typesetting for Typst + +![Banner of Basho](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/example/banner.svg) + +Basho (芭蕉) is a vertical Japanese typesetting (tategaki / 縦書き) package for Typst. It handles character boxes, tate-chu-yoko (TCY), ruby (furigana), automatic pagination, multi-column RTL layout, and kinsoku shori (Japanese line-breaking rules). + +## Usage + +### Minimal example + +```typst +#import "@preview/basho:0.1.0": tate + +#set text(font: "Harano Aji Mincho") +#set page(paper: "jp-business-card") + +#show: tate + +閑さや + + 岩にしみ入る + +  蝉の声 +``` + +![Minimal example](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/example/minimal.svg) + +### Full example + +An extended example with various features is available [here](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/example/Japanese-vertical.pdf). An example of Japanese novel typeset is available [here](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/example/Japanese-novel.pdf). + +### Inline macros + +| Macro | Description | +|---|---| +| `#tcy[body]` | Tate-chu-yoko — short horizontal text or content in a vertical column | +| `#vert[body]` | Force upright (one char or content per box, no rotation) | +| `#ruby(body, rt)` | Furigana annotation (accepts any content) | +| `#turn[body]` | Rotate content 90° clockwise | +| `#vblock[body]` | Rotated block (unrestricted width) | +| `#hblock[body]` | Horizontal block (no rotation) | + +### Inline rendering + +`#tate-inline(body, config)` renders content as a vertical stack without pagination — useful inside `#hblock[...]` or other upright contexts. + +## Customization + +Basho accepts a `config` dictionary on `#tate()` to tweak layout and rendering: + +```typst +#tate(config: ( + layout: (columns: 2, paragraph-indent: 1.5em), + sizing: (char-box: 1.2em), +))[...] +``` + +See [docs/configuration.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/configuration.md) for the full options reference and [docs/extending.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/extending.md) for custom modules. + +### Feature peek + +![Vertical layout with ruby annotations and multi-column](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/example/features-peek-1.svg) + +
+ +Show code + +```typst +#import "@preview/basho:0.1.0": hblock, ruby, tate, vblock +#set text(font: "Harano Aji Mincho") +#set page(width: 450pt,height: 350pt) + + +#tate(config: (layout: (columns: 2)))[ + = ポラーノの広場 + + そのころわたくしは、モリーオ市の博物局に勤めて居りました。 +  十八等官でしたから役所のなかでも、ずうっと下の方でしたし#ruby("俸給", "ほうきゅう")もほんのわずかでしたが、受持ちが標本の採集や整理で生れ付き好きなことでしたから、わたくしは毎日ずいぶん愉快にはたらきました。殊にそのころ、モリーオ市では競馬場を植物園に#ruby("拵", "こしら")え直すというのでその景色のいいまわりにアカシヤを植え込んだ広い地面が、切符売場や信号所の建物のついたまま、わたくしどもの役所の方へまわって来たものですから、わたくしはすぐ宿直という名前で月賦で買った小さな蓄音器と二十枚ばかりのレコードをもって、その番小屋にひとり住むことになりました。わたくしはそこの馬を置く場所に板で小さなしきいをつけて一疋の山羊を飼いました。毎 +] +``` + +
+ +![Math equations and tables](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/example/features-peek-2.svg) + +
+ +Show code + +```typst +#import "@preview/basho:0.1.0": hblock, ruby, tate, vblock +#set text(font: "Harano Aji Mincho") +#set page(width: 450pt,height: 350pt) + +#tate[ + == Fourier変換 + 次によって定義されるFourier変換 + $ + integral_(-oo)^(oo) f(x) e^(-2 pi i k x) d x, quad "where" x, k in R + $ + は位置空間$x$から波数空間$k$への変換である。 + + == 形容詞の活用表 + #hblock(table( + columns: 2, + tate[ク活用], [], + tate[から], tate[未然形], + tate[かり], tate[連用形], + tate[◯], tate[終止形], + tate[かる], tate[連体形], + tate[かれ], tate[命令形], + )) + + == 短冊 + #rect( + fill: rgb(255, 240, 240), + tate( + [奥山に 紅葉踏みわけ 鳴く鹿の + + 声きく時ぞ 秋は悲しき], + ), + ) +] +``` + +
+ +--- + +## Architecture + +Basho renders vertical text through a 5-stage pipeline built on a **Dependency Injection** architecture — every component (rendering transforms, TCY classification, kinsoku rules, list modules) is pluggable via a single `config` dictionary. + +```mermaid +flowchart LR + Input["Input content"] --> Flatten["1. Flatten"] + Flatten --> Transform["2. Transform"] + Transform --> Classify["3. Classify"] + Classify --> Paginate["4. Paginate"] + Paginate --> Render["5. Render"] + Render --> Output["Output"] +``` + +## Learn more + +| Document | Topics | +|---|---| +| [docs/architecture.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/architecture.md) | Full pipeline details, token types, node-renderer dispatch table, source map | +| [docs/configuration.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/configuration.md) | `config` deep-merge, full default-opts, factory function reference, override examples | +| [docs/kinsoku.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/kinsoku.md) | JIS X 4051 priority tiers, `default-resolver()` parameters, custom resolve functions | +| [docs/layout-hooks.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/layout-hooks.md) | Custom page layouts via hooks, bullet/numbered list module replacement | +| [docs/token-schema.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/token-schema.md) | All token types, fields, and helper functions | +| [docs/modules.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/modules.md) | Module contracts for TCY, rendering, kinsoku, and list modules | +| [docs/extending.md](https://github.com/KoyaTofu42/typst-basho/blob/0f49f8bbd95b5b2cc62d4393a3bccc25127f7ea3/docs/extending.md) | Step-by-step guide to writing custom modules | + +## License + +MIT diff --git a/packages/preview/basho/0.1.0/lib.typ b/packages/preview/basho/0.1.0/lib.typ new file mode 100644 index 0000000000..54b4677b74 --- /dev/null +++ b/packages/preview/basho/0.1.0/lib.typ @@ -0,0 +1,35 @@ +// lib.typ +// Public API for Basho — Vertical Japanese Typesetting + +#import "src/main.typ": hblock, ruby, tate, tate-inline, tcy, turn, vblock, vert +#import "src/kinsoku/kinsoku.typ": default-resolver +#import "src/config.typ": default-opts, default-rendering-params +#import "src/kinsoku/spacing.typ": default-spacing +#import "src/components/turn.typ": default-turn +#import "src/components/vblock.typ": default-vblock +#import "src/components/hblock.typ": default-hblock + +/// Standalone helpers are exported for building custom resolvers +#let kinsoku = { + import "src/kinsoku/kinsoku.typ": * + ( + "if-forbidden-start": is-forbidden-start, + "is-forbidden-end": is-forbidden-end, + "is-hanging": is-hanging, + "is-unbreakable-pair": is-unbreakable-pair, + "is-compressible-punctuation": is-compressible-punctuation, + "calculate-shrinkable-space": calculate-shrinkable-space, + "apply-spacing-compression": apply-spacing-compression, + "get-compressible-amount": get-compressible-amount, + "count-justification-points": count-justification-points, + "justify-line": justify-line, + "is-valid-line-end": is-valid-line-end, + "default-resolver": default-resolver, + ) +} + +/// Token utilities are exported for building custom transforms and classifiers +#let token-schema = { + import "src/pipeline/token.typ": * + ("token": token, "merge-token": merge-token, "is-token-type": is-token-type) +} diff --git a/packages/preview/basho/0.1.0/src/components/char-box.typ b/packages/preview/basho/0.1.0/src/components/char-box.typ new file mode 100644 index 0000000000..185251658a --- /dev/null +++ b/packages/preview/basho/0.1.0/src/components/char-box.typ @@ -0,0 +1,61 @@ +// src/char-box.typ +// Base character box rendering + +/// Wraps a single character in a 1em × 1em box with vertical OpenType features. +/// Alignment within the box depends on bracket type: +/// - Opening brackets (「 etc.) → left-aligned (or right-aligned depending on convention) +/// - Closing brackets (」 etc.) → right-aligned (or left-aligned depending on convention) +/// - All other characters → center-aligned +/// +/// For U+2015 (Horizontal Bar / vertical dash), the text is rendered at +/// `rendering.dash-scale` size so consecutive dashes concatenate seamlessly. +/// +/// - body (content): The character content to render. +/// - font (str): Font family name. +/// - config (dictionary): The layout configuration. +/// - h-align (alignment): Horizontal alignment override. +/// -> content: A box containing the vertically-oriented character. +#let char-box( + body, + font, + config, + h-align: center, + v-align: horizon, + height: none, +) = { + let render-module = config.rendering.first() + let f-opt = if font != none { (font: font) } else { (:) } + + // Half-width spaces render as a narrow vertical gap (default 0.25em) + if body == "\u{0020}" { + let space-width = config.at("space-width", default: 0.25em) + return box(width: config.sizing.char-box, height: space-width) + } + + let inner = if type(body) == str { + if body == "―" { + text( + ..f-opt, + size: render-module.dash-scale, + features: config.features, + body, + ) + } else { + text( + ..f-opt, + features: config.features, + body, + ) + } + } else { + body + } + + let box-height = if height != none { height } else { config.sizing.char-box } + box( + width: config.sizing.char-box, + height: box-height, + clip: false, + align(h-align + v-align, inner), + ) +} diff --git a/packages/preview/basho/0.1.0/src/components/hblock.typ b/packages/preview/basho/0.1.0/src/components/hblock.typ new file mode 100644 index 0000000000..2a1a4b755c --- /dev/null +++ b/packages/preview/basho/0.1.0/src/components/hblock.typ @@ -0,0 +1,13 @@ +// src/hblock.typ +#let render-hblock(token, config) = { + box( + height: config.at("usable-height", default: auto), + align(center + horizon, token.text), + ) +} + +#let default-hblock = ( + node-renderers: ( + "hblock": render-hblock, + ), +) diff --git a/packages/preview/basho/0.1.0/src/components/list.typ b/packages/preview/basho/0.1.0/src/components/list.typ new file mode 100644 index 0000000000..28444e631b --- /dev/null +++ b/packages/preview/basho/0.1.0/src/components/list.typ @@ -0,0 +1,75 @@ +// src/list.typ +// Self-contained list modules: each bundles data and flatten logic. + +#import "../pipeline/token.typ": token + +/// Default bullet list module factory. +/// +/// - marker (str): The bullet character. Default: "・". +/// -> dictionary: A list module dict with `marker`, `flatten`, and `node-renderers`. +#let default-bullet-list-params( + marker: "・", +) = { + ( + marker: marker, + flatten: (c, _flatten, config) => { + let tokens = () + for i in range(c.children.len()) { + if i > 0 { tokens.push(token("newline", fields: (text: "\n"))) } + tokens.push(token("bullet-list-marker")) + tokens += _flatten(c.children.at(i).body, config) + } + tokens + }, + node-renderers: ( + "bullet-list-marker": (token, config) => { + let f-opt = if config.font != none { (font: config.font) } else { (:) } + let marker = config.list.bullet.marker + box( + width: config.sizing.char-box, + height: config.sizing.char-box, + align(center + horizon, text( + ..f-opt, + features: config.features, + marker, + )), + ) + }, + ), + ) +} + +/// Default numbered list module factory. +/// The formatted number (e.g. "1.") is rendered as forced TCY so the +/// digits and dot stay in a single 1em slot. +/// +/// - format (function): (int) => str. Default: n => str(n) + ".". +/// - gap (length): Gap after the number before the item text. Default: 0.25em. +/// -> dictionary: A list module dict with `format`, `gap`, and `flatten`. +#let default-numbered-list-params( + format: n => str(n) + ".", + gap: 0.25em, +) = { + ( + format: format, + gap: gap, + flatten: (c, _flatten, config) => { + let tokens = () + let start = c.at("start", default: 1) + for i in range(c.children.len()) { + if i > 0 { tokens.push(token("newline", fields: (text: "\n"))) } + let num = (config.list.numbered.format)(start + i) + tokens.push(token("tcy", fields: ( + text: num, + forced: true, + list-marker: true, + ))) + let gap = config.list.numbered.gap + if gap != 0pt { tokens.push(token("spacing", fields: (width: gap))) } + tokens += _flatten(c.children.at(i).body, config) + } + tokens + }, + node-renderers: (:), + ) +} diff --git a/packages/preview/basho/0.1.0/src/components/tcy.typ b/packages/preview/basho/0.1.0/src/components/tcy.typ new file mode 100644 index 0000000000..a669be15bb --- /dev/null +++ b/packages/preview/basho/0.1.0/src/components/tcy.typ @@ -0,0 +1,65 @@ +// src/tcy.typ +// Tate-chu-yoko (TCY / 縦中横) processing module + +#import "../pipeline/token.typ": merge-token, token + +/// Default TCY module factory. +/// Returns a self-contained TCY processing module with configurable pattern and sizes. +/// +/// - pattern (regex): Regex matching TCY-character runs. Default: `^[A-Za-z0-9,]+$`. +/// - sizes (array): Font sizes for len <=2, ==3, >=4. Default: `(1em, 0.65em, 0.5em)`. +/// -> dictionary: A TCY module dict with a `filter` function. +#let default-tcy( + pattern: regex("^[A-Za-z0-9,.!?:;]+$"), + sizes: (1em, 0.65em, 0.5em), +) = { + ( + pattern: pattern, + sizes: sizes, + filter: (tokens, config) => { + let new-tokens = () + let classify-fn = none + if "categories" in config and "classify" in config.categories { + classify-fn = config.categories.classify + } + for t in tokens { + if t.type == "tcy" { + let forced = t.at("forced", default: false) + if forced != false { + if forced == "char" { + if type(t.text) == str { + for ch in t.text.clusters() { + new-tokens.push(merge-token(t, (type: "char", text: ch))) + } + } else { + new-tokens.push(merge-token(t, (type: "char", text: t.text))) + } + } else { + new-tokens.push(t) + } + } else if classify-fn != none { + let cat = classify-fn(t.text, config) + if cat == "horizontal" { + new-tokens.push(t) + } else if cat == "rotated" { + new-tokens.push(merge-token(t, (type: "turn", text: t.text))) + } else { + if type(t.text) == str { + for ch in t.text.clusters() { + new-tokens.push(merge-token(t, (type: "char", text: ch))) + } + } else { + new-tokens.push(merge-token(t, (type: "char", text: t.text))) + } + } + } else { + new-tokens.push(t) + } + } else { + new-tokens.push(t) + } + } + new-tokens + }, + ) +} diff --git a/packages/preview/basho/0.1.0/src/components/turn.typ b/packages/preview/basho/0.1.0/src/components/turn.typ new file mode 100644 index 0000000000..650a094e61 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/components/turn.typ @@ -0,0 +1,71 @@ +// src/turn.typ +// Turn module for rotating generic content (like math equations) 90 degrees + +/// Renders arbitrary content rotated 90 degrees clockwise. +/// The bounding box automatically reflows to reserve the correct vertical space. +/// ideal for equations, figures, or complex nested content. +/// +/// - token (dictionary): A token with type "turn" and text field (content). +/// - config (dictionary): The layout configuration. +/// -> content: Rotated content inside a container bounded horizontally by the column width. +#let render-turn(token, config) = { + let heading-level = token.at("heading", default: none) + let scales = config.sizing.heading-scales + let font-scale = if heading-level == 1 { scales.at(0) } else if ( + heading-level == 2 + ) { scales.at(1) } else if ( + heading-level == 3 + ) { scales.at(2) } else { 1.0 } + + let is-str = type(token.text) == str + let heading-str = heading-level != none and is-str + let f-opt = if config.font != none { (font: config.font) } else { (:) } + let size-opt = if heading-level != none { + (size: config.sizing.char-box * font-scale) + } else { (:) } + let weight-opt = if ( + heading-level != none or token.at("bold", default: false) + ) { (weight: "bold") } else { (:) } + let style-opt = if token.at("italic", default: false) { + (style: "italic") + } else { (:) } + + let base = if is-str { + text( + ..f-opt, + ..size-opt, + ..weight-opt, + ..style-opt, + token.text, + ) + } else { + token.text + } + + let styled = if is-str { base } else { + let s = base + if token.at("bold", default: false) { s = strong(s) } + if token.at("italic", default: false) { s = emph(s) } + s + } + + let inner = rotate(90deg, reflow: true, styled) + if font-scale != 1.0 and not heading-str { + inner = scale(x: font-scale, y: font-scale, inner) + } + + // We use a box with a fixed horizontal width (char-box) to keep it centered + // in the vertical column, but let the height auto-calculate based on the rotated content. + box( + width: config.sizing.char-box * font-scale, + align(center + horizon, inner), + ) +} + +/// Default turn rendering module. +/// Bundles the renderer for "turn" tokens. +#let default-turn = ( + node-renderers: ( + "turn": render-turn, + ), +) diff --git a/packages/preview/basho/0.1.0/src/components/vblock.typ b/packages/preview/basho/0.1.0/src/components/vblock.typ new file mode 100644 index 0000000000..6250b2a6b1 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/components/vblock.typ @@ -0,0 +1,13 @@ +// src/vblock.typ +#let render-vblock(token, config) = { + box( + height: config.at("usable-height", default: auto), + align(center + horizon, rotate(90deg, reflow: true, token.text)), + ) +} + +#let default-vblock = ( + node-renderers: ( + "vblock": render-vblock, + ), +) diff --git a/packages/preview/basho/0.1.0/src/config.typ b/packages/preview/basho/0.1.0/src/config.typ new file mode 100644 index 0000000000..453ea44315 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/config.typ @@ -0,0 +1,171 @@ +// src/config.typ +// Configuration state and merge engine for Basho DI architecture + +#import "kinsoku/kinsoku.typ": default-resolver +#import "components/tcy.typ": default-tcy +#import "kinsoku/spacing.typ": default-spacing + +// --------------------------------------------------------------------------- +// Default rendering module factory — self-contained +// --------------------------------------------------------------------------- + +/// Default rendering module factory. +/// Bundles character normalization, dash scaling, and custom node renderers. +/// +/// - dash-scale (length): Font size for horizontal-bar character. Default: 1.25em. +/// - node-renderers (dictionary): Custom token-type renderers. Default: (:). +/// -> dictionary: A rendering module dict with `dash-scale`, `node-renderers`, and `transform`. +#let default-rendering-params( + dash-scale: 1.25em, + node-renderers: (:), +) = { + ( + dash-scale: dash-scale, + node-renderers: node-renderers, + transform: (tokens, config) => { + tokens.map(t => { + if t.type == "char" and (t.text == "—" or t.text == "─") { + t.text = "―" + } + t + }) + }, + ) +} + +// --------------------------------------------------------------------------- +// Default sizing factory +// --------------------------------------------------------------------------- + +/// Sizing parameters factory. +/// +/// - char-box (length): Width/height of the character box. Default: 1em. +/// - ruby-size (length): Font size for ruby text. Default: 0.5em. +/// - ruby-offset (length): Horizontal offset for ruby text from the left edge. Default: 1em. +/// - heading-scales (array): Font scale factors for h1, h2, h3. Default: (1.5, 1.3, 1.15). +/// -> dictionary: A sizing dict. +#let default-sizing-params( + char-box: 1em, + ruby-size: 0.5em, + ruby-offset: 1em, + heading-scales: (1.5, 1.3, 1.15), +) = { + ( + char-box: char-box, + ruby-size: ruby-size, + ruby-offset: ruby-offset, + heading-scales: heading-scales, + ) +} + +// --------------------------------------------------------------------------- +// Default categories factory +// --------------------------------------------------------------------------- + +/// Categories parameters factory. +/// Provides the TCY classification function used by the default TCY filter. +/// +/// - classify (function): (text, config) => "horizontal" | "rotated" | "char". +/// Default: 1-2 digit numbers → "horizontal", rest → "rotated". +/// -> dictionary: A categories dict. +#let default-categories( + classify: (text, config) => { + if text.match(regex("^[0-9]{1,2}$")) != none { return "horizontal" } + return "rotated" + }, +) = { + (classify: classify) +} + +// --------------------------------------------------------------------------- +// Default layout factory +// --------------------------------------------------------------------------- + +/// Layout parameters factory. +/// +/// - columns (int): Number of horizontal rows (段組み). Default: 1. +/// - gap (length): Gap between columns within a row. Default: 1em. +/// - column-gap (length): Gap between rows (vertical). Default: 2em. +/// - paragraph-indent (length): First-line indent for each paragraph (字下げ). Default: 1em. +/// - paragraph-spacing (length): Extra spacing inserted between paragraphs. Default: 0em. +/// - hooks (array): Array of (cols, font, gap, config) => content; last wins. Default: (). +/// -> dictionary: A layout config dict. +#let default-layout-params( + columns: 1, + gap: 1em, + column-gap: 2em, + paragraph-indent: 1em, + paragraph-spacing: 0em, + hooks: (), +) = { + ( + columns: columns, + gap: gap, + column-gap: column-gap, + paragraph-indent: paragraph-indent, + paragraph-spacing: paragraph-spacing, + hooks: hooks, + ) +} + +#import "components/turn.typ": default-turn +#import "components/vblock.typ": default-vblock +#import "components/hblock.typ": default-hblock +#import "components/list.typ": ( + default-bullet-list-params, default-numbered-list-params, +) + + +// --------------------------------------------------------------------------- +// Default options +// --------------------------------------------------------------------------- + +/// Default options dictionary for Basho. +#let default-opts = ( + font: none, + features: ("vert", "vrt2"), + sizing: default-sizing-params(), + categories: default-categories(), + layout: default-layout-params(), + kinsoku: default-resolver(), + tcy: (default-tcy(),), + rendering: ( + default-rendering-params(), + default-spacing(), + default-turn, + default-vblock, + default-hblock, + ), + list: ( + bullet: default-bullet-list-params(), + numbered: default-numbered-list-params(), + ), +) + +// --------------------------------------------------------------------------- +// Merge engine +// --------------------------------------------------------------------------- + +/// Recursively merges a user configuration dictionary into a base configuration. +/// Ensures nested dictionaries are merged rather than overwritten completely. +/// Arrays (like kinsoku, tcy, rendering) are replaced wholesale — this is +/// intentional so users can swap out entire module arrays. +/// +/// - base (dictionary): The base configuration (e.g., default-opts). +/// - user (dictionary): The user's configuration overrides. +/// -> dictionary: The merged configuration. +#let merge-config(base, user) = { + let result = base + for (key, val) in user { + if ( + key in result + and type(result.at(key)) == dictionary + and type(val) == dictionary + ) { + result.insert(key, merge-config(result.at(key), val)) + } else { + result.insert(key, val) + } + } + result +} diff --git a/packages/preview/basho/0.1.0/src/kinsoku/kinsoku-builtin.typ b/packages/preview/basho/0.1.0/src/kinsoku/kinsoku-builtin.typ new file mode 100644 index 0000000000..1967f036a3 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/kinsoku/kinsoku-builtin.typ @@ -0,0 +1,63 @@ +// src/kinsoku/kinsoku-builtin.typ +#import "kinsoku-utils.typ": * + +// Built-in resolve function +// Implements the priority-based resolution from the kinsoku spec: +// 1. Unbreakable sequences → push-previous +// 2. Forbidden-start (Gyoto): +// a. Hanging (burasagari) → burasagari +// b. Compressible (oikomi) → oikomi(amount) +// c. Otherwise → push-previous +// 3. Forbidden-end (Gyomatsu) → push-previous +// 4. Default → oidashi +// --------------------------------------------------------------------------- + +#let builtin-resolve(col, token, h, config, cur-h, max-h) = { + let k = config.kinsoku + let last = if col.len() > 0 { col.last() } else { none } + + // Priority 0: Unbreakable pairs (Buntetsu Kinsoku) + if ( + k.at("buntetsu-kinsoku", default: true) + and is-unbreakable-pair(last, token, k.unbreakable-chars) + ) { + return (action: "push-previous") + } + + // Priority 1–3: Forbidden-start (Gyoto Kinsoku) + if is-forbidden-start(token, k.forbidden-start) { + let next-token = k.at("next-token", default: none) + + // Priority 1: Burasagari — hanging punctuation + if is-hanging(token, k.hanging) and k.mode == "burasagari" { + if is-forbidden-start(next-token, k.forbidden-start) { + return (action: "push-previous") + } + return (action: "burasagari") + } + + // Priority 2: Oikomi — spacing compression + let shrinkable = calculate-shrinkable-space(col, config) + let overflow = (cur-h + h) - max-h + + if k.mode == "oikomi" and shrinkable >= overflow { + if is-forbidden-start(next-token, k.forbidden-start) { + return (action: "push-previous") + } + return (action: "oikomi", compression-amount: overflow) + } + + // Priority 3: Oidashi cascading into push-previous + return (action: "push-previous") + } + + // Check Gyomatsu Kinsoku (Forbidden End) + if is-forbidden-end(last, k.forbidden-end) { + return (action: "push-previous") + } + + // Default: break normally + (action: "oidashi") +} + +// --------------------------------------------------------------------------- diff --git a/packages/preview/basho/0.1.0/src/kinsoku/kinsoku-utils.typ b/packages/preview/basho/0.1.0/src/kinsoku/kinsoku-utils.typ new file mode 100644 index 0000000000..60d8117e5f --- /dev/null +++ b/packages/preview/basho/0.1.0/src/kinsoku/kinsoku-utils.typ @@ -0,0 +1,161 @@ +// src/kinsoku.typ +// Japanese line-breaking rules (禁則処理) +// +// Exports: +// - default-resolver(..) — factory that returns a resolver dict with +// all character sets + built-in resolve function +// - Standalone helpers for custom resolvers + +// --------------------------------------------------------------------------- +// Character-check helpers (reusable by custom resolvers) +// --------------------------------------------------------------------------- + +/// Checks whether a token is a forbidden-start character (Gyoto / 行頭禁則). +/// Such characters must NOT appear at the start of a column. +#let is-forbidden-start(token, chars) = { + if token == none { return false } + if token.type != "char" { return false } + chars.contains(token.text) +} + +/// Checks whether a token is a forbidden-end character (Gyomatsu / 行末禁則). +/// Such characters must NOT appear at the end of a column. +#let is-forbidden-end(token, chars) = { + if token == none { return false } + if token.type != "char" { return false } + chars.contains(token.text) +} + +/// Checks whether a token is hanging-punctuation (burasagari / ぶら下がり). +/// Such characters can visually overflow into the gutter. +#let is-hanging(token, chars) = { + if token == none { return false } + if token.type != "char" { return false } + chars.contains(token.text) +} + +/// Checks whether two adjacent tokens form an unsplittable sequence +/// (Buntetsu Kinsoku / 分割禁則), e.g. consecutive dashes or ellipses: —— …… +#let is-unbreakable-pair(prev, current, chars) = { + if prev == none or current == none { return false } + if prev.type != "char" or current.type != "char" { return false } + chars.contains(prev.text) and prev.text == current.text +} + +/// Checks whether a token is eligible for spacing compression (Oikomi / 追い込み). +/// Yakumono (約物 / punctuation) typically has 0.5em of compressible space. +#let is-compressible-punctuation(token, chars) = { + if token == none { return false } + if token.type != "char" { return false } + chars.contains(token.text) +} + +// --------------------------------------------------------------------------- +// Computation helpers (reusable by custom resolvers) +// --------------------------------------------------------------------------- + +/// Calculates the total amount of shrinkable space in a column. +/// Two-stage compression: first counts available internal-aki, then space-after. +#let calculate-shrinkable-space(col, config) = { + let cb = config.char-box-abs + let total = 0pt + for token in col { + let internal = token.at("internal-aki", default: 0.0) * cb + let applied = token.at("compression-applied", default: 0pt) + let available-internal = calc.max(0pt, internal - applied) + total += available-internal + token.at("space-after", default: 0pt) + } + total +} + +/// Distributes compression across tokens in the column in two stages: +/// 1. Compress internal-aki. +/// 2. If space is still needed, compress space-after. +#let apply-spacing-compression(col, amount, config) = { + let cb = config.char-box-abs + let remaining = amount + let result = () + + // Stage 1: Compress internal-aki + let stage1 = () + for token in col { + if remaining > 0pt { + let internal = token.at("internal-aki", default: 0.0) * cb + let applied = token.at("compression-applied", default: 0pt) + let available = calc.max(0pt, internal - applied) + if available > 0pt { + let reduction = calc.min(available, remaining) + token.insert("compression-applied", applied + reduction) + remaining -= reduction + } + } + stage1.push(token) + } + + // Stage 2: Compress space-after + for token in stage1 { + if remaining > 0pt { + let space = token.at("space-after", default: 0pt) + if space > 0pt { + let reduction = calc.min(space, remaining) + token.insert("space-after", space - reduction) + remaining -= reduction + } + } + result.push(token) + } + + result +} + +/// Returns the compressible amount for a single token. +#let get-compressible-amount(token, config) = { + if token == none { return 0pt } + let cb = config.char-box-abs + let internal = token.at("internal-aki", default: 0.0) * cb + let applied = token.at("compression-applied", default: 0pt) + let available = calc.max(0pt, internal - applied) + available + token.at("space-after", default: 0pt) +} + +/// Returns the number of justification points in the column. +#let count-justification-points(col) = { + let count = 0 + for token in col { + if token.at("justification-point", default: false) { + count += 1 + } + } + count +} + +/// Distributes available space across justification points. +#let justify-line(col, available-space, config) = { + let count = count-justification-points(col) + if count > 0 and available-space > 0pt { + let add = available-space / count + let max-stretch = config.kinsoku.at("max-stretch", default: none) + if max-stretch != none { + add = calc.min(add, max-stretch * config.at("char-box-abs", default: 1em)) + } + let result = () + for token in col { + if token.at("justification-point", default: false) { + token.insert("space-after", token.at("space-after", default: 0pt) + add) + } + result.push(token) + } + return result + } + col +} + +/// Checks whether a token is valid at the end of a column. +/// Returns false for null tokens and forbidden-end characters. +#let is-valid-line-end(token, forbidden-end-chars) = { + if token == none { return false } + if token.type != "char" { return true } + not forbidden-end-chars.contains(token.text) +} + +// --------------------------------------------------------------------------- diff --git a/packages/preview/basho/0.1.0/src/kinsoku/kinsoku.typ b/packages/preview/basho/0.1.0/src/kinsoku/kinsoku.typ new file mode 100644 index 0000000000..05122b151d --- /dev/null +++ b/packages/preview/basho/0.1.0/src/kinsoku/kinsoku.typ @@ -0,0 +1,39 @@ +// src/kinsoku/kinsoku.typ +#import "kinsoku-utils.typ": * +#import "kinsoku-builtin.typ": builtin-resolve + +// Default resolver factory +// Returns a complete kinsoku configuration dictionary. +// Users override any field by passing named arguments. +// The `resolve` function is the built-in algorithm; set `resolve` to replace +// it entirely while keeping helper access via imports. +// --------------------------------------------------------------------------- + +#let default-resolver( + forbidden-start: ")〕]}〉》」』】)]}〞\u{201d}\u{2019}。、,.・:;ー~ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ!?", + forbidden-end: "(〔[{〈《「『【([{〝\u{201c}\u{2018}", + hanging: "、。,.", + unbreakable-chars: "—―…‥", + buntetsu-kinsoku: true, + compressible-punctuation: "、。,.", + mode: "burasagari", + compression-per-punct: 0.5, + consecutive-compression: 0.25, + max-stretch: 0.5, + resolve-fn: none, +) = { + let rfn = if resolve-fn != none { resolve-fn } else { builtin-resolve } + ( + forbidden-start: forbidden-start, + forbidden-end: forbidden-end, + hanging: hanging, + unbreakable-chars: unbreakable-chars, + buntetsu-kinsoku: buntetsu-kinsoku, + compressible-punctuation: compressible-punctuation, + mode: mode, + compression-per-punct: compression-per-punct, + consecutive-compression: consecutive-compression, + max-stretch: max-stretch, + resolve: rfn, + ) +} diff --git a/packages/preview/basho/0.1.0/src/kinsoku/spacing.typ b/packages/preview/basho/0.1.0/src/kinsoku/spacing.typ new file mode 100644 index 0000000000..667c7b2142 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/kinsoku/spacing.typ @@ -0,0 +1,121 @@ +// src/kinsoku/spacing.typ +// Automatic spacing module (Shikiri / Wou-Kan Kakaku) + +#import "../pipeline/token.typ": merge-token, token +#import "../kinsoku/kinsoku.typ": ( + is-forbidden-end, is-forbidden-start, is-unbreakable-pair, +) + +#let is-alphanumeric(t) = { + if t == none or (t.type != "char" and t.type != "tcy" and t.type != "turn") { + return false + } + if type(t.text) != str { return false } + t.text.match(regex("^[A-Za-z0-9,.!?:;]+$")) != none +} + +#let is-japanese(t) = { + if t == none or t.type != "char" { return false } + if type(t.text) != str { return false } + t.text.match(regex("^[^\x00-\x7F]+$")) != none +} + +#let is-opening-bracket(t, chars) = { + is-forbidden-end(t, chars) +} + +#let is-closing-bracket(t) = { + if t == none or t.type != "char" { return false } + let closing = ")〕]}〉》」』】)]}〞”’" + closing.contains(t.text) +} + +#let is-justification-point(t, forbidden-start, forbidden-end) = { + if t == none or t.type != "char" { return false } + ( + not is-opening-bracket(t, forbidden-end) + and not is-forbidden-start(t, forbidden-start) + ) +} + +/// Default spacing rendering module factory. +/// Automatically assigns `space-after` values according to adjacency rules. +/// +/// - cjk-european-gap (length): Gap after a CJK char before a European char. Default: 0.25em. +/// - european-cjk-gap (length): Gap after a European char before a CJK char. Default: 0.25em. +/// - bracket-gap (length): Gap after a closing bracket before an opening bracket. Default: 0.5em. +/// -> dictionary: A rendering module dict with `node-renderers` and `transform`. +#let default-spacing( + cjk-european-gap: 0.25em, + european-cjk-gap: 0.25em, + bracket-gap: 0.5em, +) = { + ( + node-renderers: ( + "spacing": (token, config) => box( + width: config.sizing.char-box, + height: token.width, + ), + ), + transform: (tokens, config) => { + let result = () + let len = tokens.len() + for i in range(len) { + let t = tokens.at(i) + let next-t = if i + 1 < len { tokens.at(i + 1) } else { none } + + let space-after = 0pt + if is-alphanumeric(t) and is-japanese(next-t) { + space-after = european-cjk-gap + } else if is-japanese(t) and is-alphanumeric(next-t) { + space-after = cjk-european-gap + } else if ( + is-closing-bracket(t) + and is-opening-bracket(next-t, config.kinsoku.forbidden-end) + ) { + space-after = bracket-gap + } + + let is-jp = is-justification-point( + t, + config.kinsoku.forbidden-start, + config.kinsoku.forbidden-end, + ) + + if ( + config.kinsoku.at("buntetsu-kinsoku", default: true) + and is-unbreakable-pair(t, next-t, config.kinsoku.unbreakable-chars) + ) { + space-after = 0pt + is-jp = false + } + + let base-width = 1.0 + let internal-aki = 0.0 + if ( + is-opening-bracket(t, config.kinsoku.forbidden-end) + or is-closing-bracket(t) + ) { + internal-aki = 0.5 + } else if ( + t != none + and t.type == "char" + and type(t.text) == str + and "、。,.".contains(t.text) + ) { + internal-aki = 0.5 + } + + t = merge-token(t, ( + space-after: space-after, + justification-point: is-jp, + base-width: base-width, + internal-aki: internal-aki, + compression-applied: 0pt, + )) + result.push(t) + } + result + }, + ) +} diff --git a/packages/preview/basho/0.1.0/src/layout/layout.typ b/packages/preview/basho/0.1.0/src/layout/layout.typ new file mode 100644 index 0000000000..8f3dd6930b --- /dev/null +++ b/packages/preview/basho/0.1.0/src/layout/layout.typ @@ -0,0 +1,245 @@ +// src/layout/layout.typ +// Vertical layout entry point + +#import "paginate.typ": paginate +#import "page.typ": render-column, render-page +#import "../renderer/renderer.typ": render-char-token + +/// Main layout entry point. Two-pass approach: +/// Pass 1: layout() measures page dims + actual token heights, paginates into columns. +/// Pass 2: context block reads pre-computed columns and renders with pagebreaks. +/// +/// - tokens (array): Array of token dictionaries. +/// - config (dictionary): Configuration dictionary. +/// -> content: Fully paginated vertical text. +#let layout-tate(tokens, config) = { + if tokens.len() == 0 { + return [] + } + + [ + #set par(spacing: 0pt) + #set block(spacing: 0pt) + + // Measure and render within the current flow so height is respected. + #layout(size => context { + let heights = tokens.map(token => { + if ( + token.type == "newline" + or token.type == "parbreak" + or token.type == "heading-anchor" + ) { + 0pt + } else { + measure(render-char-token(token, config)).height + } + }) + + let col-gap-abs = measure(v(config.layout.column-gap)).height + let top-margin = if type(page.margin) == dictionary { + page.margin.top + } else { page.margin } + let top-margin-abs = measure(box(height: top-margin)).height + let bottom-margin = if type(page.margin) == dictionary { + page.margin.bottom + } else { page.margin } + let bottom-margin-abs = measure(box(height: bottom-margin)).height + let page-height-abs = measure(box(height: page.height)).height + let page-body-height = ( + page-height-abs - top-margin-abs - bottom-margin-abs + ) + let y-in-body = here().position().y - top-margin-abs + let in-page-context = ( + size.height >= page-body-height - 5pt + and size.height <= page-body-height + 5pt + ) + let available-height = if ( + in-page-context and y-in-body >= 0pt and y-in-body <= size.height + ) { + size.height - y-in-body + } else { + // Clamp to container height when the margin assumption is invalid + // (e.g., inside box or table cells, not on a page body). + size.height + } + let gap-abs = measure(h(config.layout.gap)).width + + let cfg = config + // Resolve char-box to absolute for kinsoku compression calculations + let char-box-abs = measure(box( + width: config.sizing.char-box, + height: config.sizing.char-box, + )).height + cfg.insert("char-box-abs", char-box-abs) + + // Resolve paragraph-indent and paragraph-spacing to absolute lengths + let par-indent = cfg.layout.at("paragraph-indent", default: 1em) + let par-indent-abs = measure(box(height: par-indent)).height + cfg.insert("paragraph-indent-abs", par-indent-abs) + + let par-spacing = cfg.layout.at("paragraph-spacing", default: 0em) + let par-spacing-abs = measure(box(height: par-spacing)).height + cfg.insert("paragraph-spacing-abs", par-spacing-abs) + + let num-segments = cfg.layout.columns + + // First page columns divide remaining height after any preceding content + // (e.g. headings). Subsequent pages divide the full page body height. + let first-usable = ( + (available-height - (num-segments - 1) * col-gap-abs) / num-segments + ) + let full-usable = ( + (size.height - (num-segments - 1) * col-gap-abs) / num-segments + ) + + let result = [] + + // Helper: render one page's segments from a column array + let render-page-from-cols(cols, col-widths, start-idx, height) = { + let ri = start-idx + let page-rows = () + let seg = 0 + while seg < num-segments { + if ri >= cols.len() { break } + let seg-cols = () + let seg-w = 0pt + while ri < cols.len() { + let w = col-widths.at(ri) + let add = if seg-cols.len() == 0 { w } else { w + gap-abs } + if seg-w > 0pt and seg-w + add > size.width { break } + seg-cols.push(cols.at(ri)) + seg-w += add + ri += 1 + } + if seg-cols.len() > 0 { + page-rows.push(render-page(seg-cols, cfg.layout.gap, cfg)) + } + seg += 1 + } + (new-idx: ri, rows: page-rows) + } + + if first-usable >= full-usable { + // Single phase — no heading offset (or negative), use full height. + cfg.insert("usable-height", full-usable) + let cols = paginate(tokens, heights, full-usable, cfg) + let col-widths = cols.map(col => measure(render-column(col, cfg)).width) + + let i = 0 + while i < cols.len() { + if i > 0 { result += colbreak() } + let page = render-page-from-cols(cols, col-widths, i, full-usable) + i = page.new-idx + if page.rows.len() > 0 { + result += stack( + dir: ttb, + spacing: cfg.layout.column-gap, + ..page.rows, + ) + } + } + } else { + // Two-phase: first page uses remaining height, rest use full page height. + + // Phase 1 — paginate with first-page height + cfg.insert("usable-height", first-usable) + let cols = paginate(tokens, heights, first-usable, cfg) + let col-widths = cols.map(col => measure(render-column(col, cfg)).width) + + // Count columns on the first page + let first-count = 0 + let tmp = 0 + for segment in range(num-segments) { + if tmp >= cols.len() { break } + let seg-w = 0pt + while tmp < cols.len() { + let w = col-widths.at(tmp) + let add = if seg-w == 0pt { w } else { w + gap-abs } + if seg-w > 0pt and seg-w + add > size.width { break } + seg-w += add + tmp += 1 + first-count += 1 + } + } + + // Count tokens consumed on the first page. + // paginate() skips newline/parbreak tokens (they are not put into columns), + // so we must advance past them to keep the slice aligned. + // Also, parbreak handling injects synthetic spacing tokens into columns + // that do not correspond to any source token — exclude those from the count. + let consumed = 0 + for col-idx in range(first-count) { + let col-tokens = cols.at(col-idx) + let remaining = col-tokens + .filter(t => t.at("text", default: "") != "" or t.type != "spacing") + .len() + while remaining > 0 { + if consumed >= tokens.len() { break } + if ( + tokens.at(consumed).type == "newline" + or tokens.at(consumed).type == "parbreak" + ) { + consumed += 1 + } else { + consumed += 1 + remaining -= 1 + } + } + } + + // Render first page + let first-page = render-page-from-cols( + cols, + col-widths, + 0, + first-usable, + ) + if first-page.rows.len() > 0 { + result += stack( + dir: ttb, + spacing: cfg.layout.column-gap, + ..first-page.rows, + ) + } + + // Phase 2 — paginate remaining tokens with full page height + if consumed < tokens.len() { + result += colbreak() + cfg.insert("usable-height", full-usable) + let rest-cols = paginate( + tokens.slice(consumed), + heights.slice(consumed), + full-usable, + cfg, + ) + let rest-col-widths = rest-cols.map(col => { + measure(render-column(col, cfg)).width + }) + + let ri = 0 + while ri < rest-cols.len() { + if ri > 0 { result += colbreak() } + let page = render-page-from-cols( + rest-cols, + rest-col-widths, + ri, + full-usable, + ) + ri = page.new-idx + if page.rows.len() > 0 { + result += stack( + dir: ttb, + spacing: cfg.layout.column-gap, + ..page.rows, + ) + } + } + } + } + + result + }) + ] +} + + diff --git a/packages/preview/basho/0.1.0/src/layout/page.typ b/packages/preview/basho/0.1.0/src/layout/page.typ new file mode 100644 index 0000000000..3333b00a90 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/layout/page.typ @@ -0,0 +1,41 @@ +// src/layout/page.typ +// Rendering columns and pages + +#import "../renderer/renderer.typ": render-char-token + +/// Renders a single column of tokens as a top-to-bottom vertical stack. +/// +/// - tokens (array): Array of token dictionaries for this column. +/// - config (dictionary): Layout configuration. +/// -> content: A vertical stack of rendered character boxes. +#let render-column(tokens, config) = { + if tokens.len() == 0 { + return box(width: config.sizing.char-box, height: config.sizing.char-box) + } + + let rendered = tokens.map(token => render-char-token(token, config)) + + stack( + dir: ttb, + spacing: 0pt, + ..rendered, + ) +} + +/// Renders a single page worth of columns arranged RTL. +/// +/// - cols (array): Array of column token arrays for this page. +/// - gap (length): Horizontal gap between columns. +/// - config (dictionary): The layout configuration. +/// -> content: RTL-arranged vertical columns. +#let render-page(cols, gap, config) = { + if config.layout.hooks.len() > 0 { + return config.layout.hooks.last()(cols, config.font, gap, config) + } + let rendered = cols.map(col => render-column(col, config)) + align(right + top, stack( + dir: rtl, + spacing: gap, + ..rendered, + )) +} diff --git a/packages/preview/basho/0.1.0/src/layout/paginate.typ b/packages/preview/basho/0.1.0/src/layout/paginate.typ new file mode 100644 index 0000000000..c138b6773b --- /dev/null +++ b/packages/preview/basho/0.1.0/src/layout/paginate.typ @@ -0,0 +1,253 @@ +// src/layout/paginate.typ +// Pagination logic for splitting tokens into columns + +#import "../kinsoku/kinsoku.typ": ( + apply-spacing-compression, is-forbidden-start, is-valid-line-end, + justify-line, +) +#import "../pipeline/token.typ": merge-token + +/// Splits a flat token array into column groups based on a maximum height +/// (absolute length). Uses pre-measured token heights for accuracy, +/// and consults config.kinsoku.resolve for line-breaking decisions. +/// +/// - tokens (array): Array of token dictionaries. +/// - heights (array): Parallel array of absolute heights per token. +/// - max-height (length): Maximum column height as absolute length. +/// - config (dictionary): The layout configuration. +/// -> array: Array of arrays, each sub-array is one column's tokens. +#let paginate(tokens, heights, max-height, config) = { + let columns = () + let current-col = () + let current-height = 0pt + + // Add paragraph indent for the very first line + let par-indent-abs = config.at("paragraph-indent-abs", default: 0pt) + + let is-first-line-list = false + for j in range(0, tokens.len()) { + let peek = tokens.at(j) + if ( + peek.type == "bullet-list-marker" + or peek.at("list-marker", default: false) + ) { + is-first-line-list = true + break + } + if ( + peek.type != "newline" + and peek.type != "parbreak" + and peek.type != "heading-anchor" + ) { + break + } + } + + if par-indent-abs > 0pt and not is-first-line-list { + let indent-token = ( + type: "spacing", + text: "", + width: config.layout.at("paragraph-indent", default: 1em), + ) + current-col.push(indent-token) + current-height += par-indent-abs + } + + let i = 0 + while i < tokens.len() { + let token = tokens.at(i) + let h = heights.at(i) + + if token.type == "newline" { + let prev-type = if i > 0 { tokens.at(i - 1).type } else { none } + if current-col.len() > 0 or prev-type in ("newline", "parbreak") { + columns.push(current-col) + } + current-col = () + current-height = 0pt + i += 1 + continue + } + + if token.type == "parbreak" { + let prev-type = if i > 0 { tokens.at(i - 1).type } else { none } + if current-col.len() > 0 or prev-type in ("newline", "parbreak") { + columns.push(current-col) + } + current-col = () + current-height = 0pt + + // Add paragraph spacing between paragraphs + let par-spacing-abs = config.at("paragraph-spacing-abs", default: 0pt) + if par-spacing-abs > 0pt { + let spacing-token = ( + type: "spacing", + text: "", + width: config.layout.at("paragraph-spacing", default: 0em), + ) + current-col.push(spacing-token) + current-height += par-spacing-abs + } + + // Add paragraph indent + let par-indent-abs = config.at("paragraph-indent-abs", default: 0pt) + + let is-list = false + for j in range(i + 1, tokens.len()) { + let peek = tokens.at(j) + if ( + peek.type == "bullet-list-marker" + or peek.at("list-marker", default: false) + ) { + is-list = true + break + } + if ( + peek.type != "newline" + and peek.type != "parbreak" + and peek.type != "heading-anchor" + ) { + break + } + } + + if par-indent-abs > 0pt and not is-list { + let indent-token = ( + type: "spacing", + text: "", + width: config.layout.at("paragraph-indent", default: 1em), + ) + current-col.push(indent-token) + current-height += par-indent-abs + } + + i += 1 + continue + } + + if token.type == "vblock" or token.type == "hblock" { + if current-col.len() > 0 { + columns.push(current-col) + } + columns.push((token,)) + current-col = () + current-height = 0pt + i += 1 + continue + } + + if current-height > 0pt and current-height + h > max-height { + config.kinsoku.insert("next-token", if i + 1 < tokens.len() { + tokens.at(i + 1) + } else { none }) + let decision = (config.kinsoku.resolve)( + current-col, + token, + h, + config, + current-height, + max-height, + ) + + if decision.action == "burasagari" { + let hanging-token = merge-token(token, (type: "hanging")) + current-col.push(hanging-token) + columns.push(current-col) + current-col = () + current-height = 0pt + i += 1 + continue + } + + if decision.action == "oikomi" { + current-col = apply-spacing-compression( + current-col, + decision.compression-amount, + config, + ) + current-col.push(token) + columns.push(current-col) + current-col = () + current-height = 0pt + i += 1 + continue + } + + if decision.action == "push-previous" { + let popped = () + let popped-height = 0pt + let popped-start = i - current-col.len() + while current-col.len() > 0 { + let p = current-col.pop() + let ph = heights.at(popped-start + current-col.len()) + popped.insert(0, p) + popped-height += ph + + let new-last = if current-col.len() > 0 { current-col.last() } else { + none + } + if is-valid-line-end(new-last, config.kinsoku.forbidden-end) { + // If the overflow token is forbidden-start and the new column + // would start with one too, cascade further to prevent a + // column-start kinsoku violation. + let tok-start = is-forbidden-start( + token, + config.kinsoku.forbidden-start, + ) + let pop-start = ( + popped.len() > 0 + and is-forbidden-start( + popped.first(), + config.kinsoku.forbidden-start, + ) + ) + let needs-more = tok-start and pop-start + if not needs-more { + break + } + } + } + + let exhausted = current-col.len() == 0 + current-col = justify-line( + current-col, + max-height - (current-height - popped-height), + config, + ) + columns.push(current-col) + if exhausted { + // All tokens were popped but the violation persists. + // Force oidashi to prevent infinite loop. + current-col = (token,) + current-height = h + i += 1 + } else { + current-col = popped + current-height = popped-height + } + continue + } + + // oidashi — break normally before the current token + current-col = justify-line( + current-col, + max-height - current-height, + config, + ) + columns.push(current-col) + current-col = (token,) + current-height = h + } else { + current-col.push(token) + current-height += h + } + + i += 1 + } + + if current-col.len() > 0 { + columns.push(current-col) + } + + columns +} diff --git a/packages/preview/basho/0.1.0/src/main.typ b/packages/preview/basho/0.1.0/src/main.typ new file mode 100644 index 0000000000..adc3d040aa --- /dev/null +++ b/packages/preview/basho/0.1.0/src/main.typ @@ -0,0 +1,95 @@ +// src/main.typ +// Implementation — all functions re-exported through lib.typ + +#import "layout/layout.typ": layout-tate +#import "pipeline/flatten.typ": flatten +#import "pipeline/transform.typ": apply-transforms +#import "pipeline/classify.typ": apply-classifiers +#import "config.typ": default-opts, merge-config +#import "utils/validate.typ": validate-config +#import "renderer/renderer.typ": render-char-token + +/// Forces a sequence of characters to be rendered as Tate-chu-yoko (inline horizontal). +/// +/// - body (content): The text or content to render horizontally. +/// -> content: Metadata tag instructing the engine to render as TCY. +#let tcy(body) = metadata((type: "tcy", text: body, forced: true)) + +/// Forces a sequence of characters to be rendered upright (vertical), one per box. +/// Useful for short Latin abbreviations (e.g. "JIS") that should appear upright +/// in vertical text rather than rotated. +/// +/// - body (content): The text or content to render upright. +/// -> content: Metadata tag instructing the engine to render as upright chars. +#let vert(body) = metadata((type: "tcy", text: body, forced: "char")) + +/// Renders arbitrary content rotated 90 degrees clockwise. +/// Useful for vertical equations, figures, or nested blocks where you want +/// to preserve native font settings. +/// +/// - body (content): The content to rotate. +/// -> content: Metadata tag instructing the engine to render as rotated content. +#let turn(body) = metadata((type: "turn", text: body)) + +/// Renders arbitrary content rotated 90 degrees clockwise without restricting width. +/// Ideal for multiline equations or block elements that stretch horizontally forever. +#let vblock(body) = metadata((type: "vblock", text: body)) + +/// Renders arbitrary content upright (not rotated) in the middle of a paragraph. +/// Ideal for figures, images, or elements that should maintain their original orientation. +#let hblock(body) = metadata((type: "hblock", text: body)) + +/// Attaches phonetic ruby (furigana) to base characters. +/// +/// - body (content): The base text or content (e.g. "漢字"). +/// - rt (content): The ruby text or content (e.g. "かんじ"). +/// -> content: Metadata tag instructing the engine to render with ruby. +#let ruby(body, rt) = metadata((type: "ruby", text: body, ruby: rt)) + +/// Renders native Typst content vertically (tategaki / 縦書き). +/// +/// - body (content | str): The content to render vertically. +/// - config (dictionary): Custom Dependency Injection configuration. +/// -> content: Vertically rendered paginated content. +#let tate(body, config: (:)) = { + let cfg = merge-config(default-opts, config) + validate-config(cfg) + + cfg.rendering.push(cfg.list.bullet) + cfg.rendering.push(cfg.list.numbered) + + let tokens = flatten(body, cfg) + tokens = apply-transforms(tokens, cfg) + tokens = apply-classifiers(tokens, cfg) + + layout-tate(tokens, cfg) +} + +/// Renders native Typst content vertically inline (no pagination). +/// Use when you need vertical text inside shapes or inline blocks. +/// +/// - body (content | str): The content to render vertically. +/// - config (dictionary): Custom Dependency Injection configuration. +/// -> content: Inline vertical stack of rendered glyphs. +#let tate-inline(body, config: (:)) = { + let cfg = merge-config(default-opts, config) + validate-config(cfg) + + let tokens = flatten(body, cfg) + tokens = apply-transforms(tokens, cfg) + tokens = apply-classifiers(tokens, cfg) + + let rendered = tokens + .filter(token => ( + token.type != "newline" + and token.type != "parbreak" + and token.type != "heading-anchor" + )) + .map(token => render-char-token(token, cfg)) + + stack( + dir: ttb, + spacing: 0pt, + ..rendered, + ) +} diff --git a/packages/preview/basho/0.1.0/src/pipeline/classify.typ b/packages/preview/basho/0.1.0/src/pipeline/classify.typ new file mode 100644 index 0000000000..e93213e366 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/pipeline/classify.typ @@ -0,0 +1,12 @@ +/// Apply all TCY classifiers from config.tcy in order. +/// Each module may export a `filter(tokens, config) => tokens` function. +/// +/// - tokens (array): Token array from the transform stage. +/// - config (dictionary): Full Basho config. +/// -> array: Classified token array. +#let apply-classifiers(tokens, config) = { + for module in config.tcy { + tokens = (module.filter)(tokens, config) + } + tokens +} diff --git a/packages/preview/basho/0.1.0/src/pipeline/flatten.typ b/packages/preview/basho/0.1.0/src/pipeline/flatten.typ new file mode 100644 index 0000000000..0d2e9b0d67 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/pipeline/flatten.typ @@ -0,0 +1,195 @@ +// src/flatten.typ +// Content tree traversal for Typst native markup + +#import "../pipeline/parser.typ": tokenize +#import "../pipeline/token.typ": merge-token, token + +// --------------------------------------------------------------------------- +// Element Handlers +// --------------------------------------------------------------------------- + +#let _handle-heading(c, config, flatten-fn) = { + let tokens = () + tokens.push(token("newline", fields: (text: "\n"))) + let level = c.at("depth", default: c.at("level", default: 1)) + tokens.push(token("heading-anchor", fields: (level: level, body: c.body))) + let inner = flatten-fn(c.body, config) + inner = inner.map(t => merge-token(t, (heading: level))) + tokens += inner + tokens.push(token("parbreak", fields: (text: "\n"))) + tokens +} + +#let _handle-link(c, config, flatten-fn) = { + let dest = c.dest + let inner = flatten-fn(c.body, config) + inner.map(t => merge-token(t, (dest: dest))) +} + +#let _handle-style(c, config, flatten-fn, fname) = { + let inner = flatten-fn(c.body, config) + if fname == "strong" { + inner.map(t => merge-token(t, (bold: true))) + } else if fname == "emph" { + inner.map(t => merge-token(t, (italic: true))) + } else { + inner + } +} + +#let _handle-enum(c, config, flatten-fn) = { + let tokens = () + let items = () + if c.has("children") { + if c.children.len() == 1 { + let only = c.children.at(0) + if type(only) == content and repr(only.func()) == "item" { + let inner = only.body + if ( + type(inner) == content + and repr(inner.func()) == "sequence" + and inner.has("children") + ) { + for child in inner.children { + if type(child) == content and repr(child.func()) == "item" { + items.push(child) + } + } + } + } + } else { + for child in c.children { + if type(child) == content and repr(child.func()) == "item" { + items.push(child) + } + } + } + } + + if items.len() > 0 { + let start = c.at("start", default: 1) + for i in range(items.len()) { + if i > 0 { tokens.push(token("newline", fields: (text: "\n"))) } + let num = (config.list.numbered.format)(start + i) + tokens.push(token("tcy", fields: ( + text: num, + forced: true, + list-marker: true, + ))) + let gap = config.list.numbered.gap + if gap != 0pt { tokens.push(token("spacing", fields: (width: gap))) } + tokens += flatten-fn(items.at(i).body, config) + } + } else { + tokens += (config.list.numbered.flatten)(c, flatten-fn, config) + } + tokens +} + +#let _handle-sequence(c, config, flatten-fn) = { + let tokens = () + let seen-item = false + for child in c.children { + if type(child) == content and repr(child.func()) == "item" { + if seen-item { tokens.push(token("newline", fields: (text: "\n"))) } + tokens.push(token("bullet-list-marker")) + tokens += flatten-fn(child.body, config) + seen-item = true + } else if type(child) == content and repr(child.func()) == "space" { + // Ignore whitespace nodes inserted by list syntax. + } else if ( + type(child) == content + and child.has("children") + and child.children.len() == 0 + ) { + // Ignore empty sequence children from list syntax. + } else if ( + type(child) == content and child.has("text") and child.text == "" + ) { + // Ignore empty text nodes. + } else { + tokens += flatten-fn(child, config) + } + } + tokens +} + +// --------------------------------------------------------------------------- +// Main Flatten Logic +// --------------------------------------------------------------------------- + +/// Flattens a native Typst content tree into an array of Basho tokens. +/// This enables support for inline macros (like `#ruby`) and native styling (like `*bold*`). +/// +/// - c (content | str | array): The content to flatten. +/// - config (dictionary): The layout configuration. +/// -> array: An array of token dictionaries. +#let flatten(c, config) = { + let tokens = () + + if type(c) == array { + for child in c { + tokens += flatten(child, config) + } + } else if type(c) == str { + tokens += tokenize(c, config) + } else if type(c) == content { + let fname = repr(c.func()) + // Be tolerant to list element shapes to avoid falling back to hblock. + let is-bullet-list = ( + fname == "list" or (c.has("children") and c.has("marker")) + ) + let is-numbered-list = ( + fname == "enum" or (c.has("children") and c.has("numbering")) + ) + + if fname == "metadata" { + if type(c.value) == dictionary and "type" in c.value { + // Custom macros like ruby() or tcy() injected via metadata + tokens.push(c.value) + } + } else if fname == "equation" { + let is-block = c.at("block", default: false) + if is-block { + tokens.push(token("vblock", fields: (text: c))) + } else { + tokens.push(token("turn", fields: (text: c))) + } + } else if fname == "space" { + tokens.push(token("char", fields: (text: " "))) + } else if fname == "parbreak" { + tokens.push(token("parbreak", fields: (text: "\n"))) + } else if fname == "linebreak" { + tokens.push(token("newline", fields: (text: "\n"))) + } else if fname == "heading" { + tokens += _handle-heading(c, config, flatten) + } else if fname == "link" { + tokens += _handle-link(c, config, flatten) + } else if ( + fname + in ("strong", "emph", "underline", "strike", "overline", "highlight") + ) { + tokens += _handle-style(c, config, flatten, fname) + } else if fname == "enum" { + tokens += _handle-enum(c, config, flatten) + } else if fname == "sequence" and c.has("children") { + tokens += _handle-sequence(c, config, flatten) + } else if is-bullet-list { + tokens += (config.list.bullet.flatten)(c, flatten, config) + } else if is-numbered-list { + tokens += (config.list.numbered.flatten)(c, flatten, config) + } else if c.has("children") { + for child in c.children { + tokens += flatten(child, config) + } + } else if c.has("text") { + // Native text elements + tokens += tokenize(c.text, config) + } else { + // Anything else (figures, images, shapes, unhandled blocks) + tokens.push(token("hblock", fields: (text: c))) + } + } + + tokens +} diff --git a/packages/preview/basho/0.1.0/src/pipeline/parser.typ b/packages/preview/basho/0.1.0/src/pipeline/parser.typ new file mode 100644 index 0000000000..d58321cad1 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/pipeline/parser.typ @@ -0,0 +1,54 @@ +// src/parser.typ +// String → token array conversion + +#import "../pipeline/token.typ": token + +/// Tests whether a character cluster is an ASCII Latin letter, digit, or comma. +/// +/// - ch (str): A single character cluster. +/// - config (dictionary): The layout configuration. +/// -> bool +#let is-tcy-char(ch, config) = { + ch.match(config.tcy.first().pattern) != none +} + +/// Splits an input string into an array of tokens. +/// - Newline characters → type "newline" +/// - Consecutive ASCII Latin/digit/comma runs → type "tcy" +/// - Everything else → type "char" (one per cluster) +/// +/// - input (str): The string to tokenize. +/// - config (dictionary): The layout configuration. +/// -> array: Array of token dictionaries. +#let tokenize(input, config) = { + if input == "" { + return () + } + + let tokens = () + let tcy-buf = "" + + for ch in input.clusters() { + if ch == "\n" { + if tcy-buf != "" { + tokens.push(token("tcy", fields: (text: tcy-buf))) + tcy-buf = "" + } + tokens.push(token("newline", fields: (text: "\n"))) + } else if is-tcy-char(ch, config) { + tcy-buf += ch + } else { + if tcy-buf != "" { + tokens.push(token("tcy", fields: (text: tcy-buf))) + tcy-buf = "" + } + tokens.push(token("char", fields: (text: ch))) + } + } + + if tcy-buf != "" { + tokens.push(token("tcy", fields: (text: tcy-buf))) + } + + tokens +} diff --git a/packages/preview/basho/0.1.0/src/pipeline/token.typ b/packages/preview/basho/0.1.0/src/pipeline/token.typ new file mode 100644 index 0000000000..5cc130d745 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/pipeline/token.typ @@ -0,0 +1,31 @@ +/// Create a token dictionary with the given type and optional fields. +/// +/// - type (str): The token type (e.g. "char", "tcy", "newline"). +/// - fields (dictionary): Optional key-value pairs to merge into the token. +/// -> dictionary: A token dictionary with at least a `type` field. +#let token(type, fields: (:)) = { + let result = (type: type) + for (k, v) in fields { + result.insert(k, v) + } + result +} + +/// Merge additional fields into a token, returning a new token (non-mutating). +/// Works as a safe wrapper around dictionary merge. +/// +/// - t (dictionary): The base token. +/// - fields (dictionary): Fields to merge in. +/// -> dictionary: A new token with all fields from t plus fields. +#let merge-token(t, fields) = { + t + fields +} + +/// Check whether a token has a given type. +/// +/// - t (dictionary): The token to check. +/// - expected (str): The expected type string. +/// -> bool +#let is-token-type(t, expected) = { + t.type == expected +} diff --git a/packages/preview/basho/0.1.0/src/pipeline/transform.typ b/packages/preview/basho/0.1.0/src/pipeline/transform.typ new file mode 100644 index 0000000000..4859cde44b --- /dev/null +++ b/packages/preview/basho/0.1.0/src/pipeline/transform.typ @@ -0,0 +1,14 @@ +/// Apply all rendering transforms from config.rendering in order. +/// Each module may export a `transform(tokens) => tokens` function. +/// +/// - tokens (array): Token array from the flatten stage. +/// - config (dictionary): Full Basho config. +/// -> array: Transformed token array. +#let apply-transforms(tokens, config) = { + for module in config.rendering { + if "transform" in module { + tokens = (module.transform)(tokens, config) + } + } + tokens +} diff --git a/packages/preview/basho/0.1.0/src/renderer/renderer.typ b/packages/preview/basho/0.1.0/src/renderer/renderer.typ new file mode 100644 index 0000000000..8094f3bbdc --- /dev/null +++ b/packages/preview/basho/0.1.0/src/renderer/renderer.typ @@ -0,0 +1,176 @@ +// src/renderer.typ +// Character box rendering with OpenType vertical glyph features + +#import "../kinsoku/kinsoku.typ": is-forbidden-end, is-forbidden-start + +#import "../components/char-box.typ": char-box + +/// Renders a TCY (tate-chu-yoko / 縦中横) run: short horizontal text displayed +/// with normal horizontal glyphs, centered within a 1em × 1em slot in the +/// vertical column flow. No rotation, no vertical OpenType features. +/// Typically used for 2-digit numbers ("42") or short abbreviations ("IT"). +/// Font size adapts to string length so text fits within the 1em column width. +/// +/// - token (dictionary): A token with type "tcy" and text field. +/// - config (dictionary): The layout configuration. +/// -> content: Horizontal text in a 1em × 1em box. +#let render-tcy(token, config) = { + let f-opt = if config.font != none { (font: config.font) } else { (:) } + let tcy-module = config.tcy.first() + let sizes = tcy-module.sizes + + let inner = if type(token.text) == str { + let len = token.text.clusters().len() + let sz = if len <= 2 { sizes.at(0) } else if len <= 3 { sizes.at(1) } else { + calc.min(sizes.at(2) / 1em, 1.0 / len) * config.sizing.char-box + } + text(..f-opt, size: sz, token.text) + } else { + token.text + } + + box( + width: config.sizing.char-box, + height: config.sizing.char-box, + align(center + horizon, inner), + ) +} + +/// Renders hanging punctuation (kinsoku shori): the character is drawn +/// in a zero-height box so it visually overflows into the gutter below +/// the column without affecting the column height. +/// +/// - token (dictionary): A token with type "hanging" and text field. +/// - config (dictionary): The layout configuration. +/// -> content: Zero-height box with the character. +#let render-hanging(token, config) = { + let f-opt = if config.font != none { (font: config.font) } else { (:) } + box( + width: config.sizing.char-box, + height: 0pt, + clip: false, + align(center + top, text( + ..f-opt, + features: config.features, + token.text, + )), + ) +} + +#import "ruby.typ": render-ruby + +/// Renders a single token based on its type. +/// Dispatches "char" → char-box, "tcy" → render-tcy, "hanging" → render-hanging, "ruby" → render-ruby. +/// +/// - token (dictionary): A token dictionary with at least a `type` and `text` field. +/// - config (dictionary): The layout configuration. +/// -> content: Rendered content for the token. +#let render-char-token(token, config) = { + let f-opt = if config.font != none { (font: config.font) } else { (:) } + + let rendered = none + for render-module in config.rendering { + if ( + "node-renderers" in render-module + and token.type in render-module.node-renderers + ) { + rendered = (render-module.node-renderers.at(token.type))(token, config) + break + } + } + + if rendered == none { + let heading-level = token.at("heading", default: none) + let scales = config.sizing.heading-scales + let font-scale = if heading-level == 1 { scales.at(0) } else if ( + heading-level == 2 + ) { scales.at(1) } else if ( + heading-level == 3 + ) { scales.at(2) } else { 1.0 } + + // Determine kinsoku-aware alignment from config.kinsoku character sets + let check-opening(token) = is-forbidden-end( + token, + config.kinsoku.forbidden-end, + ) + let check-closing(token) = is-forbidden-start( + token, + config.kinsoku.forbidden-start, + ) + + rendered = if token.type == "char" { + // Determine horizontal alignment based on bracket type + let h-align = if check-opening(token) { right } else if check-closing( + token, + ) { left } else { center } + // Determine vertical alignment based on bracket type to fix spacing when compressed + let v-align = horizon + + let cb = config.at("char-box-abs", default: config.sizing.char-box) + let base = token.at("base-width", default: 1.0) + let applied = token.at("compression-applied", default: 0pt) + let box-height = base * cb - applied + + if heading-level != none { + // Heading characters: scaled box + let sz = config.sizing.char-box * font-scale + box( + width: sz, + height: sz, + align(h-align + v-align, text( + ..f-opt, + size: config.sizing.char-box * font-scale, + features: config.features, + weight: "bold", + token.text, + )), + ) + } else { + char-box( + token.text, + config.font, + config, + h-align: h-align, + v-align: v-align, + height: box-height, + ) + } + } else if token.type == "tcy" { + render-tcy(token, config) + } else if token.type == "hanging" { + render-hanging(token, config) + } else if token.type == "ruby" { + render-ruby(token, config) + } else if token.type == "heading-anchor" { + box( + width: 0pt, + height: 0pt, + clip: true, + heading( + level: token.level, + outlined: true, + bookmarked: true, + token.body, + ), + ) + } else { + none + } + } + + if rendered != none and token.type != "turn" { + if token.at("bold", default: false) { rendered = strong(rendered) } + if token.at("italic", default: false) { rendered = emph(rendered) } + if "dest" in token { rendered = link(token.dest, rendered) } + } + + let space-after = token.at("space-after", default: 0pt) + if space-after != 0pt and rendered != none { + rendered = stack(dir: ttb, spacing: 0pt, rendered, box( + width: config.sizing.char-box, + height: space-after, + )) + } + + rendered +} diff --git a/packages/preview/basho/0.1.0/src/renderer/ruby.typ b/packages/preview/basho/0.1.0/src/renderer/ruby.typ new file mode 100644 index 0000000000..742c868da4 --- /dev/null +++ b/packages/preview/basho/0.1.0/src/renderer/ruby.typ @@ -0,0 +1,74 @@ +// src/ruby.typ +// Ruby (furigana) rendering + +#import "../components/char-box.typ": char-box + +/// Renders a character with ruby (furigana) on the right side. +/// The overall box is strictly 1em × 1em, with the ruby text overflowing +/// into the gutter to the right. This ensures column pitch remains consistent. +/// +/// - token (dictionary): Token with type "ruby", `text` (base), and `ruby` (reading). +/// - config (dictionary): The layout configuration. +/// -> content: Rendered ruby box. +#let render-ruby(token, config) = { + let base-is-str = type(token.text) == str + let base-chars = if base-is-str { token.text.clusters() } else { + (token.text,) + } + let base-len = if base-is-str { base-chars.len() } else { 1 } + let base-height = base-len * config.sizing.char-box + + let base-stack = stack( + dir: ttb, + spacing: 0pt, + ..base-chars.map(ch => char-box(ch, config.font, config)), + ) + + let ruby-is-str = type(token.ruby) == str + if (ruby-is-str and token.ruby == "") or token.ruby == none { + return base-stack + } + + let ruby-chars = if ruby-is-str { token.ruby.clusters() } else { + (token.ruby,) + } + let ruby-len = ruby-chars.len() + let ruby-height = ruby-len * config.sizing.ruby-size + + // Ruby text stack: characters are stacked top-to-bottom + let ruby-stack = stack( + dir: ttb, + spacing: 0pt, + ..ruby-chars.map(ch => { + let ruby-char = if type(ch) == str { + text( + size: config.sizing.ruby-size, + ..(if config.font != none { (font: config.font) } else { (:) }), + features: config.features, + ch, + ) + } else { ch } + box( + width: config.sizing.ruby-size, + height: config.sizing.ruby-size, + align(center + horizon, ruby-char), + ) + }), + ) + + // Calculate the required height to fit whichever is taller (base or ruby) + let total-height = calc.max(base-height, ruby-height) + + // Wrap in a box. The height expands to prevent overlap with adjacent tokens. + // Both base and ruby are vertically centered within this height. + // Ruby flows to the right. + box( + width: config.sizing.char-box, + height: total-height, + clip: false, + { + place(left + horizon, base-stack) + place(left + horizon, dx: config.sizing.ruby-offset, ruby-stack) + }, + ) +} diff --git a/packages/preview/basho/0.1.0/src/utils/validate.typ b/packages/preview/basho/0.1.0/src/utils/validate.typ new file mode 100644 index 0000000000..d89cd6eeeb --- /dev/null +++ b/packages/preview/basho/0.1.0/src/utils/validate.typ @@ -0,0 +1,162 @@ +/// Validate a Basho configuration dictionary. +/// Panics with a descriptive message when required fields are missing or have invalid types. +/// +/// - config (dictionary): The merged configuration to validate. +/// -> none +#let validate-config(config) = { + assert( + type(config) == dictionary, + message: "basho: config must be a dictionary", + ) + + assert( + config.font == none or type(config.font) == str, + message: "basho: config.font must be a string or none", + ) + assert( + type(config.features) == array, + message: "basho: config.features must be an array (e.g. (\"vert\", \"vrt2\"))", + ) + + if type(config.rendering) == array { + for module in config.rendering { + if "transform" in module { + assert( + type(module.transform) == function, + message: "basho: each config.rendering entry with 'transform' must have a function value, got " + + repr(type(module.transform)), + ) + } + if "node-renderers" in module { + assert( + type(module.node-renderers) == dictionary, + message: "basho: each config.rendering entry with 'node-renderers' must have a dictionary value", + ) + } + } + } else { + assert( + false, + message: "basho: config.rendering must be an array of rendering modules", + ) + } + + if type(config.tcy) == array { + assert( + config.tcy.len() > 0, + message: "basho: config.tcy must contain at least one TCY module", + ) + for tcy-mod in config.tcy { + assert( + "filter" in tcy-mod, + message: "basho: each config.tcy entry must have a 'filter' function", + ) + assert( + type(tcy-mod.filter) == function, + message: "basho: each config.tcy.filter must be a function", + ) + assert( + "pattern" in tcy-mod, + message: "basho: each config.tcy entry must have a 'pattern' field (regex)", + ) + assert( + "sizes" in tcy-mod, + message: "basho: each config.tcy entry must have a 'sizes' field (array of lengths)", + ) + } + } else { + assert(false, message: "basho: config.tcy must be an array of TCY modules") + } + + if type(config.kinsoku) == dictionary { + assert( + "resolve" in config.kinsoku, + message: "basho: config.kinsoku must have a 'resolve' function", + ) + assert( + type(config.kinsoku.resolve) == function, + message: "basho: config.kinsoku.resolve must be a function", + ) + } else { + assert( + false, + message: "basho: config.kinsoku must be a dictionary with resolver settings", + ) + } + + if type(config.list) == dictionary { + if "bullet" in config.list { + assert( + type(config.list.bullet) == dictionary, + message: "basho: config.list.bullet must be a dictionary", + ) + assert( + "flatten" in config.list.bullet, + message: "basho: config.list.bullet must have a 'flatten' function", + ) + assert( + type(config.list.bullet.flatten) == function, + message: "basho: config.list.bullet.flatten must be a function", + ) + } + if "numbered" in config.list { + assert( + type(config.list.numbered) == dictionary, + message: "basho: config.list.numbered must be a dictionary", + ) + assert( + "flatten" in config.list.numbered, + message: "basho: config.list.numbered must have a 'flatten' function", + ) + assert( + type(config.list.numbered.flatten) == function, + message: "basho: config.list.numbered.flatten must be a function", + ) + } + } else { + assert( + false, + message: "basho: config.list must be a dictionary with 'bullet' and 'numbered' keys", + ) + } + + if type(config.layout) == dictionary { + assert( + type(config.layout.columns) == int, + message: "basho: config.layout.columns must be an integer >= 1", + ) + assert( + config.layout.columns >= 1, + message: "basho: config.layout.columns must be >= 1", + ) + assert( + type(config.layout.gap) == length, + message: "basho: config.layout.gap must be a length (e.g. 1em)", + ) + assert( + type(config.layout.column-gap) == length, + message: "basho: config.layout.column-gap must be a length (e.g. 2em)", + ) + } else { + assert( + false, + message: "basho: config.layout must be a dictionary with layout parameters", + ) + } + + if type(config.sizing) == dictionary { + assert( + type(config.sizing.char-box) == length, + message: "basho: config.sizing.char-box must be a length (e.g. 1em)", + ) + } + + if "categories" in config and type(config.categories) == dictionary { + if "classify" in config.categories { + assert( + type(config.categories.classify) == function, + message: "basho: config.categories.classify must be a function returning \"horizontal\", \"rotated\", or \"char\"", + ) + } + } +} diff --git a/packages/preview/basho/0.1.0/typst.toml b/packages/preview/basho/0.1.0/typst.toml new file mode 100644 index 0000000000..379593bfdd --- /dev/null +++ b/packages/preview/basho/0.1.0/typst.toml @@ -0,0 +1,28 @@ +[package] +name = "basho" +version = "0.1.0" +authors = ["KoyaTofu"] +description = "Render vertical Japanese text (tategaki)." +entrypoint = "lib.typ" +compiler = "0.14.0" +repository = "https://github.com/KoyaTofu42/typst-basho" +exclude = ["*.pdf", "/example", "/test", "/docs"] +keywords = [ + "vertical", + "Japanese", + "日本語", + "tategaki", + "縦書き", + "yokogaki", + "横書き", + "typesetting", + "typography", + "writing-mode", + "ruby", + "furigana", + "ルビ", + "振り仮名", +] +categories = ["layout", "text", "languages"] +disciplines = ["linguistics"] +license = "MIT"