Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions bitext/src/lib/api/align.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
});
});
});
43 changes: 36 additions & 7 deletions bitext/src/lib/api/align.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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. */
Expand Down Expand Up @@ -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
};
}

Expand All @@ -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))
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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));
Expand All @@ -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 }
: {})
};
});

Expand Down
19 changes: 17 additions & 2 deletions bitext/src/lib/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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".'
}
}
}
Expand Down Expand Up @@ -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'],
Expand Down
73 changes: 70 additions & 3 deletions bitext/src/routes/api/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,22 @@
0-based left-to-right in logical order.</td
>
</tr>
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
<td class={tdClass}>orientation</td>
<td class={tdTypeClass}
><span class={codeClass}>upright</span> <span class={codeClass}>vertical</span>
<span class={codeClass}>sideways</span></td
>
<td class={tdTypeClass}>upright</td>
<td class={tdDescClass}
>How this line's glyphs are set, visible when <span class={codeClass}
>settings.axis</span
>
is <span class={codeClass}>columns</span>. <span class={codeClass}>vertical</span>
stacks the characters (Japanese, Chinese); <span class={codeClass}>sideways</span> rotates
the whole line a quarter turn (traditional Mongolian, and Latin runs inside vertical text).</td
>
</tr>
</tbody>
</table>
</div>
Expand All @@ -302,6 +318,36 @@
]
}'`}</pre>

<h3 class={subheadingClass}>Example — Japanese tategaki beside an English translation</h3>
<p class="mt-3">
<span class={codeClass}>settings.axis</span> turns the whole diagram into columns;
<span class={codeClass}>orientation</span> 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.
</p>
<pre class="{preClass} mt-3">{`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" }
}'`}</pre>
<p class="mt-3">
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
<span class={codeClass}>"orientation": "sideways"</span> instead, and keep the script line first,
since its columns read left to right.
</p>

<!-- ── Visual settings ─────────────────────────────────────── -->

<h2 id="settings" class={headingClass}>Visual settings (SettingsInput)</h2>
Expand All @@ -322,6 +368,19 @@
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/60">
<tr>
<td class={tdClass}>axis</td>
<td class={tdTypeClass}
><span class={codeClass}>rows</span> <span class={codeClass}>columns</span></td
>
<td class={tdTypeClass}>rows</td>
<td class={tdDescClass}
>Flow direction of the diagram. <span class={codeClass}>columns</span> 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
<span class={codeClass}>lines</span> to move a script to the other side.</td
>
</tr>
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
<td class={tdClass}>palette</td>
<td class={tdTypeClass}
><span class={codeClass}>pastel</span> <span class={codeClass}>vivid</span></td
Expand Down Expand Up @@ -441,19 +500,27 @@
<td class={tdClass}>upper</td>
<td class={tdTypeClass}>integer <em>required</em></td>
<td class={tdTypeClass}>—</td>
<td class={tdDescClass}>0-based index of the upper line.</td>
<td class={tdDescClass}
>0-based index of the earlier line in the stack: the upper one in
<span class={codeClass}>rows</span>, the left one in
<span class={codeClass}>columns</span>.</td
>
</tr>
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
<td class={tdClass}>lower</td>
<td class={tdTypeClass}>integer <em>required</em></td>
<td class={tdTypeClass}>—</td>
<td class={tdDescClass}>0-based index of the lower line (must equal upper + 1).</td>
<td class={tdDescClass}
>0-based index of the next line in the stack (must equal upper + 1): below in
<span class={codeClass}>rows</span>, to the right in
<span class={codeClass}>columns</span>.</td
>
</tr>
<tr>
<td class={tdClass}>gapPx</td>
<td class={tdTypeClass}>integer 12–156</td>
<td class={tdTypeClass}>120</td>
<td class={tdDescClass}>Vertical gap between the two lines in px.</td>
<td class={tdDescClass}>Gap between the two lines in px, measured across the stack.</td>
</tr>
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
<td class={tdClass}>showConnectors</td>
Expand Down
34 changes: 34 additions & 0 deletions bitext/src/routes/api/align/openapi.json/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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.'
}
}
}
Expand All @@ -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'],
Expand Down
Loading