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. +
+ 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. +
+