Skip to content

Commit 1e1a576

Browse files
authored
Merge pull request #19 from tinygodsdev/feat/vertical-writing-api
Expose vertical writing through the API, MCP, and the agent skill (BLD-137)
2 parents 14b9fd3 + e83fbd9 commit 1e1a576

7 files changed

Lines changed: 246 additions & 12 deletions

File tree

‎bitext/src/lib/api/align.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,74 @@ describe('buildAlignUrl', () => {
282282
expect(buildAlignUrl(ORIGIN, { lines })).not.toHaveProperty('err');
283283
});
284284
});
285+
286+
// ── Vertical writing ──────────────────────────────────────────────────────────
287+
288+
describe('vertical writing', () => {
289+
it('accepts the column axis and per-line orientation', () => {
290+
const parsed = parseAlignBody({
291+
lines: [
292+
'The cat ate the fish',
293+
{ text: '猫 が 魚 を 食べた', orientation: 'vertical', font: 'Noto Serif JP' }
294+
],
295+
settings: { axis: 'columns' }
296+
});
297+
if ('err' in parsed) throw new Error(parsed.err);
298+
expect(parsed.ok.settings?.axis).toBe('columns');
299+
expect(parsed.ok.lines[1]).toMatchObject({ orientation: 'vertical' });
300+
});
301+
302+
it('encodes the axis into the shared state, not as a stray key', () => {
303+
const result = buildAlignUrl(ORIGIN, {
304+
lines: ['one two', { text: '猫 魚', orientation: 'vertical' }],
305+
settings: { axis: 'columns' }
306+
});
307+
if (!('url' in result)) throw new Error(result.err);
308+
const state = decodeState(new URL(result.url).searchParams.get('data'));
309+
expect(state.settings.layoutAxis).toBe('columns');
310+
expect(state.project.lines[1]!.textOrientation).toBe('vertical');
311+
expect(state.project.lines[0]!.textOrientation).toBeUndefined();
312+
});
313+
314+
it('carries the sideways orientation for Mongolian', () => {
315+
const result = buildAlignUrl(ORIGIN, {
316+
lines: [
317+
{ text: 'ᠮᠣᠩᠭᠣᠯ ᠪᠢᠴᠢᠭ', orientation: 'sideways', font: 'Noto Sans Mongolian' },
318+
'Mongolian script'
319+
],
320+
settings: { axis: 'columns' }
321+
});
322+
if (!('url' in result)) throw new Error(result.err);
323+
const state = decodeState(new URL(result.url).searchParams.get('data'));
324+
expect(state.project.lines[0]!.textOrientation).toBe('sideways');
325+
});
326+
327+
it('defaults to rows and upright when neither is given', () => {
328+
const result = buildAlignUrl(ORIGIN, { lines: ['a b', 'c d'] });
329+
if (!('url' in result)) throw new Error(result.err);
330+
const state = decodeState(new URL(result.url).searchParams.get('data'));
331+
expect(state.settings.layoutAxis).toBe('rows');
332+
expect(state.project.lines[0]!.textOrientation).toBeUndefined();
333+
});
334+
335+
it('treats an explicit upright orientation as the default', () => {
336+
const result = buildAlignUrl(ORIGIN, {
337+
lines: [{ text: 'a b', orientation: 'upright' }, 'c d']
338+
});
339+
if (!('url' in result)) throw new Error(result.err);
340+
const state = decodeState(new URL(result.url).searchParams.get('data'));
341+
expect(state.project.lines[0]!.textOrientation).toBeUndefined();
342+
});
343+
344+
it('rejects an unknown axis', () => {
345+
expect(parseAlignBody({ lines: ['a'], settings: { axis: 'diagonal' } })).toMatchObject({
346+
err: expect.stringContaining('settings.axis')
347+
});
348+
});
349+
350+
it('rejects an unknown orientation', () => {
351+
expect(parseAlignBody({ lines: [{ text: 'a', orientation: 'upside-down' }] })).toMatchObject({
352+
err: expect.stringContaining('orientation')
353+
});
354+
});
355+
});

‎bitext/src/lib/api/align.ts‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import {
1515
type VisualSettingsV2
1616
} from '$lib/serialization/schema.js';
1717
import { PALETTES } from '$lib/domain/palettes.js';
18+
import type { LayoutAxis, TextOrientation } from '$lib/types/layout.js';
19+
20+
const TEXT_ORIENTATIONS = ['upright', 'vertical', 'sideways'] as const;
21+
const LAYOUT_AXES = ['rows', 'columns'] as const;
1822

1923
const DEFAULT_FONT_FAMILY = 'Inter';
2024
const DEFAULT_TEXT_SIZE_PX = 36;
@@ -32,12 +36,26 @@ export interface LineInput {
3236
sizePx?: number;
3337
/** Horizontal gap between word tokens in px (0–56). Defaults to 14. */
3438
gapPx?: number;
35-
/** Right-to-left layout (Hebrew, Arabic, etc.). Defaults to false. */
39+
/** Right-to-left layout (Hebrew, Arabic, etc.). Defaults to false. Ignored when orientation is "vertical". */
3640
rtl?: boolean;
41+
/**
42+
* How this line's glyphs are set. Defaults to "upright" (words render horizontally).
43+
* "vertical" stacks the characters, for Japanese and Chinese. "sideways" rotates the whole
44+
* line a quarter turn, which is what traditional Mongolian needs and what a Latin line inside
45+
* vertical text usually wants. Only visible with `settings.axis: "columns"`.
46+
*/
47+
orientation?: TextOrientation;
3748
}
3849

3950
/** Global visual settings overrides. All fields optional; unset fields inherit defaults. */
4051
export interface SettingsInput {
52+
/**
53+
* Flow direction of the whole diagram. Defaults to "rows": lines stack downward, words run
54+
* across, connectors run vertically. "columns" stands every line up as a vertical column with
55+
* connectors running sideways, for Japanese, Chinese, and Mongolian. The first line is
56+
* leftmost, so reorder `lines` to put a script on the other side.
57+
*/
58+
axis?: LayoutAxis;
4159
/** Color palette for connection lines. */
4260
palette?: 'pastel' | 'vivid';
4361
/** Connection line shape. */
@@ -113,12 +131,15 @@ function parseLineEntry(val: unknown, idx: number): LineInput | { err: string }
113131
return { err: `lines[${idx}].gapPx must be a number` };
114132
if (v.rtl !== undefined && typeof v.rtl !== 'boolean')
115133
return { err: `lines[${idx}].rtl must be a boolean` };
134+
if (v.orientation !== undefined && !TEXT_ORIENTATIONS.includes(v.orientation as TextOrientation))
135+
return { err: `lines[${idx}].orientation must be one of: ${TEXT_ORIENTATIONS.join(', ')}` };
116136
return {
117137
text: v.text,
118138
font: typeof v.font === 'string' ? v.font : undefined,
119139
sizePx: typeof v.sizePx === 'number' ? v.sizePx : undefined,
120140
gapPx: typeof v.gapPx === 'number' ? v.gapPx : undefined,
121-
rtl: typeof v.rtl === 'boolean' ? v.rtl : undefined
141+
rtl: typeof v.rtl === 'boolean' ? v.rtl : undefined,
142+
orientation: v.orientation as TextOrientation | undefined
122143
};
123144
}
124145

@@ -130,6 +151,8 @@ function parseSettingsInput(val: unknown): { ok: SettingsInput } | { err: string
130151
const STYLES = new Set(['straight', 'curved']);
131152
const THEMES_AND_BKGS = new Set(['light', 'dark']);
132153

154+
if (v.axis !== undefined && !LAYOUT_AXES.includes(v.axis as LayoutAxis))
155+
return { err: `settings.axis must be one of: ${LAYOUT_AXES.join(', ')}` };
133156
if (v.palette !== undefined && !PALETTE_NAMES.has(v.palette as string))
134157
return { err: `settings.palette must be one of: ${[...PALETTE_NAMES].join(', ')}` };
135158
if (v.lineStyle !== undefined && !STYLES.has(v.lineStyle as string))
@@ -162,6 +185,7 @@ function parseSettingsInput(val: unknown): { ok: SettingsInput } | { err: string
162185

163186
return {
164187
ok: {
188+
axis: v.axis as SettingsInput['axis'],
165189
palette: v.palette as SettingsInput['palette'],
166190
lineStyle: v.lineStyle as SettingsInput['lineStyle'],
167191
theme: v.theme as SettingsInput['theme'],
@@ -257,12 +281,14 @@ export function parseAlignBody(body: unknown): { ok: AlignRequest } | { err: str
257281
export function buildAlignUrl(origin: string, req: AlignRequest): AlignResult {
258282
const defaults = defaultVisualSettingsV2();
259283

260-
// Merge settings overrides
284+
// Merge settings overrides. `axis` is spelled differently inside (`layoutAxis`), so it is
285+
// pulled out of the spread and mapped by hand; leaving it in would add a stray key that the
286+
// compact encoder silently drops.
287+
const { axis, ...spreadableSettings } = req.settings ?? {};
261288
const visualSettings: VisualSettingsV2 = {
262289
...defaults,
263-
...(req.settings
264-
? Object.fromEntries(Object.entries(req.settings).filter(([, v]) => v !== undefined))
265-
: {})
290+
...Object.fromEntries(Object.entries(spreadableSettings).filter(([, v]) => v !== undefined)),
291+
...(axis !== undefined ? { layoutAxis: axis } : {})
266292
};
267293
// Clamp numeric settings to valid ranges
268294
visualSettings.lineThickness = Math.min(8, Math.max(1, visualSettings.lineThickness));
@@ -280,7 +306,10 @@ export function buildAlignUrl(origin: string, req: AlignRequest): AlignResult {
280306
font: { family: inp.font ?? DEFAULT_FONT_FAMILY, source: 'google' as const },
281307
textSizePx: Math.min(64, Math.max(12, inp.sizePx ?? DEFAULT_TEXT_SIZE_PX)),
282308
gapWordPx: Math.min(56, Math.max(0, inp.gapPx ?? DEFAULT_WORD_GAP_PX)),
283-
...(inp.rtl ? { rtl: true } : {})
309+
...(inp.rtl ? { rtl: true } : {}),
310+
...(inp.orientation && inp.orientation !== 'upright'
311+
? { textOrientation: inp.orientation }
312+
: {})
284313
};
285314
});
286315

‎bitext/src/lib/mcp/server.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,18 @@ const PREVIEW_WIDTH = 800;
2929

3030
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.
3131
32-
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.
32+
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.
3333
3434
Word indices are 0-based token positions. Tokenize each line the same way the tool does before assigning indices:
3535
- Whitespace always splits ("I have been going" -> I[0] have[1] been[2] going[3]).
3636
- 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.
3737
- Punctuation stays attached by default ("Hello, world!" -> Hello,[0] world![1]).
3838
- In RTL lines, word 0 is the logically first word (rightmost on screen); index in reading order.
39+
- Japanese and Chinese are written without spaces and nothing is segmented for you: put spaces where the alignment units should be.
3940
40-
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.`;
41+
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.
42+
43+
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.`;
4144

4245
const LINE_INPUT_SCHEMA = {
4346
oneOf: [
@@ -67,6 +70,12 @@ const LINE_INPUT_SCHEMA = {
6770
rtl: {
6871
type: 'boolean',
6972
description: 'Right-to-left layout (Hebrew, Arabic, ...). Default false.'
73+
},
74+
orientation: {
75+
type: 'string',
76+
enum: ['upright', 'vertical', 'sideways'],
77+
description:
78+
'How this line\'s glyphs are set, visible with settings.axis "columns". "vertical" stacks characters (Japanese, Chinese), "sideways" rotates the line (Mongolian). Default "upright".'
7079
}
7180
}
7281
}
@@ -102,6 +111,12 @@ const TOOL_INPUT_SCHEMA = {
102111
additionalProperties: false,
103112
description: 'Global visual overrides. Unset fields inherit defaults.',
104113
properties: {
114+
axis: {
115+
type: 'string',
116+
enum: ['rows', 'columns'],
117+
description:
118+
'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.'
119+
},
105120
palette: {
106121
type: 'string',
107122
enum: ['pastel', 'vivid'],

‎bitext/src/routes/api/+page.svelte‎

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,22 @@
284284
0-based left-to-right in logical order.</td
285285
>
286286
</tr>
287+
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
288+
<td class={tdClass}>orientation</td>
289+
<td class={tdTypeClass}
290+
><span class={codeClass}>upright</span> <span class={codeClass}>vertical</span>
291+
<span class={codeClass}>sideways</span></td
292+
>
293+
<td class={tdTypeClass}>upright</td>
294+
<td class={tdDescClass}
295+
>How this line's glyphs are set, visible when <span class={codeClass}
296+
>settings.axis</span
297+
>
298+
is <span class={codeClass}>columns</span>. <span class={codeClass}>vertical</span>
299+
stacks the characters (Japanese, Chinese); <span class={codeClass}>sideways</span> rotates
300+
the whole line a quarter turn (traditional Mongolian, and Latin runs inside vertical text).</td
301+
>
302+
</tr>
287303
</tbody>
288304
</table>
289305
</div>
@@ -302,6 +318,36 @@
302318
]
303319
}'`}</pre>
304320

321+
<h3 class={subheadingClass}>Example — Japanese tategaki beside an English translation</h3>
322+
<p class="mt-3">
323+
<span class={codeClass}>settings.axis</span> turns the whole diagram into columns;
324+
<span class={codeClass}>orientation</span> decides which line stacks its characters. The first line
325+
is the leftmost column, so the translation comes first and the Japanese lands on the right, where
326+
a vertical text belongs.
327+
</p>
328+
<pre class="{preClass} mt-3">{`curl -X POST ${apiBase}/api/align \\
329+
-H "Content-Type: application/json" \\
330+
-d '{
331+
"lines": [
332+
{ "text": "The cat ate the fish", "sizePx": 26 },
333+
{ "text": "猫 が 魚 を 食べた", "orientation": "vertical", "font": "Noto Serif JP", "sizePx": 34 }
334+
],
335+
"alignments": [
336+
[0, 0, 1, 0],
337+
[0, 1, 1, 0],
338+
[0, 3, 1, 2],
339+
[0, 4, 1, 2],
340+
[0, 2, 1, 4]
341+
],
342+
"settings": { "axis": "columns" }
343+
}'`}</pre>
344+
<p class="mt-3">
345+
Japanese and Chinese are written without spaces and nothing is segmented for you: put spaces
346+
where the alignment units should be. For traditional Mongolian use
347+
<span class={codeClass}>"orientation": "sideways"</span> instead, and keep the script line first,
348+
since its columns read left to right.
349+
</p>
350+
305351
<!-- ── Visual settings ─────────────────────────────────────── -->
306352

307353
<h2 id="settings" class={headingClass}>Visual settings (SettingsInput)</h2>
@@ -322,6 +368,19 @@
322368
</thead>
323369
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/60">
324370
<tr>
371+
<td class={tdClass}>axis</td>
372+
<td class={tdTypeClass}
373+
><span class={codeClass}>rows</span> <span class={codeClass}>columns</span></td
374+
>
375+
<td class={tdTypeClass}>rows</td>
376+
<td class={tdDescClass}
377+
>Flow direction of the diagram. <span class={codeClass}>columns</span> stands every line
378+
up as a vertical column and runs the connectors sideways, for Japanese, Chinese, and
379+
Mongolian. The first line is the leftmost column, so reorder
380+
<span class={codeClass}>lines</span> to move a script to the other side.</td
381+
>
382+
</tr>
383+
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
325384
<td class={tdClass}>palette</td>
326385
<td class={tdTypeClass}
327386
><span class={codeClass}>pastel</span> <span class={codeClass}>vivid</span></td
@@ -441,19 +500,27 @@
441500
<td class={tdClass}>upper</td>
442501
<td class={tdTypeClass}>integer <em>required</em></td>
443502
<td class={tdTypeClass}>—</td>
444-
<td class={tdDescClass}>0-based index of the upper line.</td>
503+
<td class={tdDescClass}
504+
>0-based index of the earlier line in the stack: the upper one in
505+
<span class={codeClass}>rows</span>, the left one in
506+
<span class={codeClass}>columns</span>.</td
507+
>
445508
</tr>
446509
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
447510
<td class={tdClass}>lower</td>
448511
<td class={tdTypeClass}>integer <em>required</em></td>
449512
<td class={tdTypeClass}>—</td>
450-
<td class={tdDescClass}>0-based index of the lower line (must equal upper + 1).</td>
513+
<td class={tdDescClass}
514+
>0-based index of the next line in the stack (must equal upper + 1): below in
515+
<span class={codeClass}>rows</span>, to the right in
516+
<span class={codeClass}>columns</span>.</td
517+
>
451518
</tr>
452519
<tr>
453520
<td class={tdClass}>gapPx</td>
454521
<td class={tdTypeClass}>integer 12–156</td>
455522
<td class={tdTypeClass}>120</td>
456-
<td class={tdDescClass}>Vertical gap between the two lines in px.</td>
523+
<td class={tdDescClass}>Gap between the two lines in px, measured across the stack.</td>
457524
</tr>
458525
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
459526
<td class={tdClass}>showConnectors</td>

‎bitext/src/routes/api/align/openapi.json/+server.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,28 @@ export const GET: RequestHandler = ({ url }) => {
5757
]
5858
}
5959
},
60+
verticalJapanese: {
61+
summary: 'Japanese tategaki beside an English translation',
62+
value: {
63+
lines: [
64+
{ text: 'The cat ate the fish', sizePx: 26 },
65+
{
66+
text: '猫 が 魚 を 食べた',
67+
orientation: 'vertical',
68+
font: 'Noto Serif JP',
69+
sizePx: 34
70+
}
71+
],
72+
alignments: [
73+
[0, 0, 1, 0],
74+
[0, 1, 1, 0],
75+
[0, 3, 1, 2],
76+
[0, 4, 1, 2],
77+
[0, 2, 1, 4]
78+
],
79+
settings: { axis: 'columns' }
80+
}
81+
},
6082
multiLine: {
6183
summary: '3 lines, gloss row with larger gap',
6284
value: {
@@ -158,6 +180,12 @@ export const GET: RequestHandler = ({ url }) => {
158180
rtl: {
159181
type: 'boolean',
160182
description: 'Right-to-left layout for Hebrew, Arabic, etc. Defaults to false.'
183+
},
184+
orientation: {
185+
type: 'string',
186+
enum: ['upright', 'vertical', 'sideways'],
187+
description:
188+
'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.'
161189
}
162190
}
163191
}
@@ -167,6 +195,12 @@ export const GET: RequestHandler = ({ url }) => {
167195
type: 'object',
168196
description: 'Visual settings overrides. Unset fields inherit defaults.',
169197
properties: {
198+
axis: {
199+
type: 'string',
200+
enum: ['rows', 'columns'],
201+
description:
202+
'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.'
203+
},
170204
palette: {
171205
type: 'string',
172206
enum: ['pastel', 'vivid'],

0 commit comments

Comments
 (0)