From a60d28091f409de4d380ddabfb0c26e4ff16b254 Mon Sep 17 00:00:00 2001 From: dani-polani Date: Sat, 8 Aug 2026 04:08:45 +0300 Subject: [PATCH] feat(api): expose vertical writing through the API, MCP, and the skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fields, both following the existing `rtl` precedent: - `settings.axis` — "rows" (default) or "columns". - `lines[].orientation` — "upright" (default), "vertical", or "sideways". The API only encodes state into a URL, so none of the layout work is involved. `axis` is spelled `layoutAxis` internally, so it is pulled out of the settings spread and mapped by hand; leaving it in would have added a stray key that the compact encoder silently drops, and the feature would have looked wired up while doing nothing. Unknown values are rejected with a named error rather than normalized, which matches how `palette` and `lineStyle` behave. Docs get the naming trap `upper`/`lower` set up: they mean earlier and later in the stack, which is above/below in rows but left/right in columns. Same correction in the MCP tool description, which also said links must be "vertically adjacent". Both now mention that Japanese and Chinese are not segmented for you. OpenAPI, the /api page, and the agent skill get a worked tategaki example. Co-Authored-By: Claude Opus 5 (1M context) --- bitext/src/lib/api/align.test.ts | 71 ++++++++++++++++++ bitext/src/lib/api/align.ts | 43 +++++++++-- bitext/src/lib/mcp/server.ts | 19 ++++- bitext/src/routes/api/+page.svelte | 73 ++++++++++++++++++- .../routes/api/align/openapi.json/+server.ts | 34 +++++++++ word-aligner-skill/SKILL.md | 16 ++++ word-aligner-skill/references/api.md | 2 + 7 files changed, 246 insertions(+), 12 deletions(-) diff --git a/bitext/src/lib/api/align.test.ts b/bitext/src/lib/api/align.test.ts index 5f14e3b..6e85cad 100644 --- a/bitext/src/lib/api/align.test.ts +++ b/bitext/src/lib/api/align.test.ts @@ -282,3 +282,74 @@ describe('buildAlignUrl', () => { expect(buildAlignUrl(ORIGIN, { lines })).not.toHaveProperty('err'); }); }); + +// ── Vertical writing ────────────────────────────────────────────────────────── + +describe('vertical writing', () => { + it('accepts the column axis and per-line orientation', () => { + const parsed = parseAlignBody({ + lines: [ + 'The cat ate the fish', + { text: '猫 が 魚 を 食べた', orientation: 'vertical', font: 'Noto Serif JP' } + ], + settings: { axis: 'columns' } + }); + if ('err' in parsed) throw new Error(parsed.err); + expect(parsed.ok.settings?.axis).toBe('columns'); + expect(parsed.ok.lines[1]).toMatchObject({ orientation: 'vertical' }); + }); + + it('encodes the axis into the shared state, not as a stray key', () => { + const result = buildAlignUrl(ORIGIN, { + lines: ['one two', { text: '猫 魚', orientation: 'vertical' }], + settings: { axis: 'columns' } + }); + if (!('url' in result)) throw new Error(result.err); + const state = decodeState(new URL(result.url).searchParams.get('data')); + expect(state.settings.layoutAxis).toBe('columns'); + expect(state.project.lines[1]!.textOrientation).toBe('vertical'); + expect(state.project.lines[0]!.textOrientation).toBeUndefined(); + }); + + it('carries the sideways orientation for Mongolian', () => { + const result = buildAlignUrl(ORIGIN, { + lines: [ + { text: 'ᠮᠣᠩᠭᠣᠯ ᠪᠢᠴᠢᠭ', orientation: 'sideways', font: 'Noto Sans Mongolian' }, + 'Mongolian script' + ], + settings: { axis: 'columns' } + }); + if (!('url' in result)) throw new Error(result.err); + const state = decodeState(new URL(result.url).searchParams.get('data')); + expect(state.project.lines[0]!.textOrientation).toBe('sideways'); + }); + + it('defaults to rows and upright when neither is given', () => { + const result = buildAlignUrl(ORIGIN, { lines: ['a b', 'c d'] }); + if (!('url' in result)) throw new Error(result.err); + const state = decodeState(new URL(result.url).searchParams.get('data')); + expect(state.settings.layoutAxis).toBe('rows'); + expect(state.project.lines[0]!.textOrientation).toBeUndefined(); + }); + + it('treats an explicit upright orientation as the default', () => { + const result = buildAlignUrl(ORIGIN, { + lines: [{ text: 'a b', orientation: 'upright' }, 'c d'] + }); + if (!('url' in result)) throw new Error(result.err); + const state = decodeState(new URL(result.url).searchParams.get('data')); + expect(state.project.lines[0]!.textOrientation).toBeUndefined(); + }); + + it('rejects an unknown axis', () => { + expect(parseAlignBody({ lines: ['a'], settings: { axis: 'diagonal' } })).toMatchObject({ + err: expect.stringContaining('settings.axis') + }); + }); + + it('rejects an unknown orientation', () => { + expect(parseAlignBody({ lines: [{ text: 'a', orientation: 'upside-down' }] })).toMatchObject({ + err: expect.stringContaining('orientation') + }); + }); +}); diff --git a/bitext/src/lib/api/align.ts b/bitext/src/lib/api/align.ts index daf1828..8cbf27c 100644 --- a/bitext/src/lib/api/align.ts +++ b/bitext/src/lib/api/align.ts @@ -15,6 +15,10 @@ import { type VisualSettingsV2 } from '$lib/serialization/schema.js'; import { PALETTES } from '$lib/domain/palettes.js'; +import type { LayoutAxis, TextOrientation } from '$lib/types/layout.js'; + +const TEXT_ORIENTATIONS = ['upright', 'vertical', 'sideways'] as const; +const LAYOUT_AXES = ['rows', 'columns'] as const; const DEFAULT_FONT_FAMILY = 'Inter'; const DEFAULT_TEXT_SIZE_PX = 36; @@ -32,12 +36,26 @@ export interface LineInput { sizePx?: number; /** Horizontal gap between word tokens in px (0–56). Defaults to 14. */ gapPx?: number; - /** Right-to-left layout (Hebrew, Arabic, etc.). Defaults to false. */ + /** Right-to-left layout (Hebrew, Arabic, etc.). Defaults to false. Ignored when orientation is "vertical". */ rtl?: boolean; + /** + * How this line's glyphs are set. Defaults to "upright" (words render horizontally). + * "vertical" stacks the characters, for Japanese and Chinese. "sideways" rotates the whole + * line a quarter turn, which is what traditional Mongolian needs and what a Latin line inside + * vertical text usually wants. Only visible with `settings.axis: "columns"`. + */ + orientation?: TextOrientation; } /** Global visual settings overrides. All fields optional; unset fields inherit defaults. */ export interface SettingsInput { + /** + * Flow direction of the whole diagram. Defaults to "rows": lines stack downward, words run + * across, connectors run vertically. "columns" stands every line up as a vertical column with + * connectors running sideways, for Japanese, Chinese, and Mongolian. The first line is + * leftmost, so reorder `lines` to put a script on the other side. + */ + axis?: LayoutAxis; /** Color palette for connection lines. */ palette?: 'pastel' | 'vivid'; /** Connection line shape. */ @@ -113,12 +131,15 @@ function parseLineEntry(val: unknown, idx: number): LineInput | { err: string } return { err: `lines[${idx}].gapPx must be a number` }; if (v.rtl !== undefined && typeof v.rtl !== 'boolean') return { err: `lines[${idx}].rtl must be a boolean` }; + if (v.orientation !== undefined && !TEXT_ORIENTATIONS.includes(v.orientation as TextOrientation)) + return { err: `lines[${idx}].orientation must be one of: ${TEXT_ORIENTATIONS.join(', ')}` }; return { text: v.text, font: typeof v.font === 'string' ? v.font : undefined, sizePx: typeof v.sizePx === 'number' ? v.sizePx : undefined, gapPx: typeof v.gapPx === 'number' ? v.gapPx : undefined, - rtl: typeof v.rtl === 'boolean' ? v.rtl : undefined + rtl: typeof v.rtl === 'boolean' ? v.rtl : undefined, + orientation: v.orientation as TextOrientation | undefined }; } @@ -130,6 +151,8 @@ function parseSettingsInput(val: unknown): { ok: SettingsInput } | { err: string const STYLES = new Set(['straight', 'curved']); const THEMES_AND_BKGS = new Set(['light', 'dark']); + if (v.axis !== undefined && !LAYOUT_AXES.includes(v.axis as LayoutAxis)) + return { err: `settings.axis must be one of: ${LAYOUT_AXES.join(', ')}` }; if (v.palette !== undefined && !PALETTE_NAMES.has(v.palette as string)) return { err: `settings.palette must be one of: ${[...PALETTE_NAMES].join(', ')}` }; if (v.lineStyle !== undefined && !STYLES.has(v.lineStyle as string)) @@ -162,6 +185,7 @@ function parseSettingsInput(val: unknown): { ok: SettingsInput } | { err: string return { ok: { + axis: v.axis as SettingsInput['axis'], palette: v.palette as SettingsInput['palette'], lineStyle: v.lineStyle as SettingsInput['lineStyle'], theme: v.theme as SettingsInput['theme'], @@ -257,12 +281,14 @@ export function parseAlignBody(body: unknown): { ok: AlignRequest } | { err: str export function buildAlignUrl(origin: string, req: AlignRequest): AlignResult { const defaults = defaultVisualSettingsV2(); - // Merge settings overrides + // Merge settings overrides. `axis` is spelled differently inside (`layoutAxis`), so it is + // pulled out of the spread and mapped by hand; leaving it in would add a stray key that the + // compact encoder silently drops. + const { axis, ...spreadableSettings } = req.settings ?? {}; const visualSettings: VisualSettingsV2 = { ...defaults, - ...(req.settings - ? Object.fromEntries(Object.entries(req.settings).filter(([, v]) => v !== undefined)) - : {}) + ...Object.fromEntries(Object.entries(spreadableSettings).filter(([, v]) => v !== undefined)), + ...(axis !== undefined ? { layoutAxis: axis } : {}) }; // Clamp numeric settings to valid ranges visualSettings.lineThickness = Math.min(8, Math.max(1, visualSettings.lineThickness)); @@ -280,7 +306,10 @@ export function buildAlignUrl(origin: string, req: AlignRequest): AlignResult { font: { family: inp.font ?? DEFAULT_FONT_FAMILY, source: 'google' as const }, textSizePx: Math.min(64, Math.max(12, inp.sizePx ?? DEFAULT_TEXT_SIZE_PX)), gapWordPx: Math.min(56, Math.max(0, inp.gapPx ?? DEFAULT_WORD_GAP_PX)), - ...(inp.rtl ? { rtl: true } : {}) + ...(inp.rtl ? { rtl: true } : {}), + ...(inp.orientation && inp.orientation !== 'upright' + ? { textOrientation: inp.orientation } + : {}) }; }); diff --git a/bitext/src/lib/mcp/server.ts b/bitext/src/lib/mcp/server.ts index f353bdf..004d895 100644 --- a/bitext/src/lib/mcp/server.ts +++ b/bitext/src/lib/mcp/server.ts @@ -31,15 +31,18 @@ const PREVIEW_WIDTH = 800; const TOOL_DESCRIPTION = `Create a shareable Word Aligner diagram that shows which words match across two or more stacked lines of text (a translation and its source, an interlinear gloss, IPA, etc.). Returns a URL that opens the interactive diagram, plus a preview image. -Use this when the user wants to translate a phrase and show word correspondences, align a translation with its source (including RTL scripts like Hebrew or Arabic), or build a Leipzig-style interlinear gloss. +Use this when the user wants to translate a phrase and show word correspondences, align a translation with its source (including RTL scripts like Hebrew or Arabic, or vertically written ones like Japanese and Mongolian), or build a Leipzig-style interlinear gloss. Word indices are 0-based token positions. Tokenize each line the same way the tool does before assigning indices: - Whitespace always splits ("I have been going" -> I[0] have[1] been[2] going[3]). - The characters in settings.tokenSplitChars (default ".-|") also split and are then removed from the rendered text, so "go.PST.IPFV" becomes three tokens (go, PST, IPFV) and the dots disappear. For Leipzig glosses set tokenSplitChars to "-|" to keep the dots. - Punctuation stays attached by default ("Hello, world!" -> Hello,[0] world![1]). - In RTL lines, word 0 is the logically first word (rightmost on screen); index in reading order. +- Japanese and Chinese are written without spaces and nothing is segmented for you: put spaces where the alignment units should be. -Each alignment is [lineA, wordA, lineB, wordB]; the two lines must be vertically adjacent (|lineA - lineB| = 1). To express many-to-one, list each target word as its own tuple. Tokens that share a connection group get the same color automatically.`; +For a vertically written script set settings.axis to "columns". Every line then becomes a vertical column and the connectors run sideways. Set orientation per line: "vertical" stacks the characters (Japanese, Chinese), "sideways" rotates the line a quarter turn (traditional Mongolian, and Latin runs inside vertical text), "upright" leaves a translation as horizontal word boxes. The first line is the leftmost column, so for Japanese and Chinese, whose columns read right to left, list the translation first and the script second. + +Each alignment is [lineA, wordA, lineB, wordB]; the two lines must be neighbours in the stack (|lineA - lineB| = 1), which means one above the other in rows and side by side in columns. To express many-to-one, list each target word as its own tuple. Tokens that share a connection group get the same color automatically.`; const LINE_INPUT_SCHEMA = { oneOf: [ @@ -69,6 +72,12 @@ const LINE_INPUT_SCHEMA = { rtl: { type: 'boolean', description: 'Right-to-left layout (Hebrew, Arabic, ...). Default false.' + }, + orientation: { + type: 'string', + enum: ['upright', 'vertical', 'sideways'], + description: + 'How this line\'s glyphs are set, visible with settings.axis "columns". "vertical" stacks characters (Japanese, Chinese), "sideways" rotates the line (Mongolian). Default "upright".' } } } @@ -104,6 +113,12 @@ const TOOL_INPUT_SCHEMA = { additionalProperties: false, description: 'Global visual overrides. Unset fields inherit defaults.', properties: { + axis: { + type: 'string', + enum: ['rows', 'columns'], + description: + 'Flow direction of the diagram. "rows" (default) stacks lines downward with vertical connectors. "columns" stands each line up as a vertical column with sideways connectors, for Japanese, Chinese, and Mongolian; the first line is the leftmost column.' + }, palette: { type: 'string', enum: ['pastel', 'vivid'], diff --git a/bitext/src/routes/api/+page.svelte b/bitext/src/routes/api/+page.svelte index 0672883..db5990d 100644 --- a/bitext/src/routes/api/+page.svelte +++ b/bitext/src/routes/api/+page.svelte @@ -284,6 +284,22 @@ 0-based left-to-right in logical order. + + orientation + upright vertical + sideways + upright + How this line's glyphs are set, visible when settings.axis + is columns. vertical + stacks the characters (Japanese, Chinese); sideways rotates + the whole line a quarter turn (traditional Mongolian, and Latin runs inside vertical text). + @@ -302,6 +318,36 @@ ] }'`} +

Example — Japanese tategaki beside an English translation

+

+ settings.axis turns the whole diagram into columns; + orientation decides which line stacks its characters. The first line + is the leftmost column, so the translation comes first and the Japanese lands on the right, where + a vertical text belongs. +

+
{`curl -X POST ${apiBase}/api/align \\
+  -H "Content-Type: application/json" \\
+  -d '{
+    "lines": [
+      { "text": "The cat ate the fish", "sizePx": 26 },
+      { "text": "猫 が 魚 を 食べた", "orientation": "vertical", "font": "Noto Serif JP", "sizePx": 34 }
+    ],
+    "alignments": [
+      [0, 0, 1, 0],
+      [0, 1, 1, 0],
+      [0, 3, 1, 2],
+      [0, 4, 1, 2],
+      [0, 2, 1, 4]
+    ],
+    "settings": { "axis": "columns" }
+  }'`}
+

+ Japanese and Chinese are written without spaces and nothing is segmented for you: put spaces + where the alignment units should be. For traditional Mongolian use + "orientation": "sideways" instead, and keep the script line first, + since its columns read left to right. +

+

Visual settings (SettingsInput)

@@ -322,6 +368,19 @@ + axis + rows columns + rows + Flow direction of the diagram. columns stands every line + up as a vertical column and runs the connectors sideways, for Japanese, Chinese, and + Mongolian. The first line is the leftmost column, so reorder + lines to move a script to the other side. + + palette pastel vividupper integer required — - 0-based index of the upper line. + 0-based index of the earlier line in the stack: the upper one in + rows, the left one in + columns. lower integer required — - 0-based index of the lower line (must equal upper + 1). + 0-based index of the next line in the stack (must equal upper + 1): below in + rows, to the right in + columns. gapPx integer 12–156 120 - Vertical gap between the two lines in px. + Gap between the two lines in px, measured across the stack. showConnectors diff --git a/bitext/src/routes/api/align/openapi.json/+server.ts b/bitext/src/routes/api/align/openapi.json/+server.ts index 805a18e..08e9096 100644 --- a/bitext/src/routes/api/align/openapi.json/+server.ts +++ b/bitext/src/routes/api/align/openapi.json/+server.ts @@ -57,6 +57,28 @@ export const GET: RequestHandler = ({ url }) => { ] } }, + verticalJapanese: { + summary: 'Japanese tategaki beside an English translation', + value: { + lines: [ + { text: 'The cat ate the fish', sizePx: 26 }, + { + text: '猫 が 魚 を 食べた', + orientation: 'vertical', + font: 'Noto Serif JP', + sizePx: 34 + } + ], + alignments: [ + [0, 0, 1, 0], + [0, 1, 1, 0], + [0, 3, 1, 2], + [0, 4, 1, 2], + [0, 2, 1, 4] + ], + settings: { axis: 'columns' } + } + }, multiLine: { summary: '3 lines, gloss row with larger gap', value: { @@ -158,6 +180,12 @@ export const GET: RequestHandler = ({ url }) => { rtl: { type: 'boolean', description: 'Right-to-left layout for Hebrew, Arabic, etc. Defaults to false.' + }, + orientation: { + type: 'string', + enum: ['upright', 'vertical', 'sideways'], + description: + 'How this line\'s glyphs are set, visible when settings.axis is "columns". "vertical" stacks the characters (Japanese, Chinese); "sideways" rotates the line a quarter turn (traditional Mongolian, and Latin runs inside vertical text). Defaults to upright.' } } } @@ -167,6 +195,12 @@ export const GET: RequestHandler = ({ url }) => { type: 'object', description: 'Visual settings overrides. Unset fields inherit defaults.', properties: { + axis: { + type: 'string', + enum: ['rows', 'columns'], + description: + 'Flow direction of the diagram. "rows" stacks lines downward with vertical connectors. "columns" stands every line up as a vertical column with sideways connectors, for Japanese, Chinese, and Mongolian; the first line is the leftmost column, so reorder lines to move a script to the other side. Default: rows.' + }, palette: { type: 'string', enum: ['pastel', 'vivid'], diff --git a/word-aligner-skill/SKILL.md b/word-aligner-skill/SKILL.md index 7862440..24a723a 100644 --- a/word-aligner-skill/SKILL.md +++ b/word-aligner-skill/SKILL.md @@ -75,6 +75,22 @@ If unsure, call `GET https://aligner.tinygods.dev/api/align?lines=your+text` fir } ``` +**Vertical writing** (Japanese, Chinese, Mongolian): set `"axis": "columns"` in settings, then `"orientation"` per line. Every line becomes a vertical column and connectors run sideways. + +```json +{ + "lines": [ + {"text": "The cat ate the fish", "sizePx": 26}, + {"text": "猫 が 魚 を 食べた", "orientation": "vertical", "font": "Noto Serif JP", "sizePx": 34} + ], + "alignments": [[0,0,1,0], [0,1,1,0], [0,3,1,2], [0,4,1,2], [0,2,1,4]], + "settings": {"axis": "columns"} +} +``` +The first line is the leftmost column. Japanese and Chinese read their columns right to left, so list the translation first and the script second. Traditional Mongolian reads left to right and uses `"orientation": "sideways"` (its letters join into a running stem and cannot be stacked one by one), so its script line comes first. + +Japanese and Chinese are written without spaces and nothing is segmented for you — put spaces where the alignment units should be. + **Interlinear (Leipzig) gloss** — three lines: gloss on top, source in the middle, free translation at the bottom. Important: a Leipzig gloss uses periods to pack grammatical features into one morpheme (`go.PST.IPFV` = "go" + past + imperfective, **one** token). The default `tokenSplitChars` is `".-|"`, which would split on the period and hide it — rendering `goPSTIPFV`. To keep the periods, set `"tokenSplitChars": "-|"` (drop the dot). diff --git a/word-aligner-skill/references/api.md b/word-aligner-skill/references/api.md index 3c928bf..ce73bc2 100644 --- a/word-aligner-skill/references/api.md +++ b/word-aligner-skill/references/api.md @@ -33,6 +33,7 @@ Each entry in `lines` is a plain string (shorthand) or an object: | `sizePx` | integer 12–64 | `36` | Text size in px | | `gapPx` | integer 0–56 | `14` | Horizontal gap between word tokens in px | | `rtl` | boolean | `false` | Right-to-left layout (Hebrew, Arabic, Farsi, Urdu, etc.) | +| `orientation` | `upright` `vertical` `sideways` | `upright` | How the glyphs are set, visible with `settings.axis: "columns"`. `vertical` stacks characters (Japanese, Chinese); `sideways` rotates the line (traditional Mongolian) | --- @@ -42,6 +43,7 @@ Global visual overrides. All fields optional; unset fields use defaults. | Field | Values | Default | Description | |---------------------|-----------------------------|-----------|-------------| +| `axis` | `rows` `columns` | `rows` | Flow direction. `columns` stands every line up as a vertical column with sideways connectors; the first line is the leftmost column | | `palette` | `pastel` `vivid` `academic` | `pastel` | Color palette for connection lines and token tints | | `lineStyle` | `curved` `straight` | `curved` | Shape of connection lines | | `lineThickness` | number 1–8 | `3` | Stroke width |