Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Short notes for changes on branch **`v2`** relative to **`main`** (no dedicated
### Added

- **Multi-line alignment** — projects and preview support more than two lines; links only between vertically adjacent lines.
- **Vertical writing systems** — a project-wide layout direction (`layoutAxis`): `rows` as before, or `columns`, where every line becomes a vertical column and connectors run sideways. Per line, a `textOrientation` of `upright`, `vertical` (stacked characters, for Japanese and Chinese), or `sideways` (rotated line, for traditional Mongolian and for Latin runs inside vertical text). Preview, all four export formats, and share links carry both. Column side follows line order; there is no separate direction setting. Gallery examples `japanese-vertical-writing-tategaki-alignment` and `traditional-mongolian-vertical-alignment`, plus the guide `/guide/vertical-writing-alignment`. Not yet exposed through the HTTP API, the MCP server, or vertical shaping of uploaded custom fonts in PNG/PDF export.
- **Line editor** — line cards (`LineCard`), modal text editing (`LineEditModal`), line settings (font, size, spacing, LTR/RTL, etc.) via popover/sheet.
- **Per-pair line controls** — vertical gap slider (`LinePairGapSlider`), toggling connector visibility for each adjacent line pair (`pairControls`).
- **Tokenization settings** — Tokens tab (word split characters, join character, optional punctuation tokenization); “?” hints (`SettingsFieldHint`).
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ No accounts, no machine translation — you stay in control of every link. Great
- **Manual word linking** — click a word, then its match; supports one-to-many and many-to-one links and freely crossing connectors for reordered translations.
- **Interlinear glosses & IPA** — add annotation tiers above or below any line.
- **Right-to-left scripts** — Hebrew, Arabic, and mixed LTR/RTL layouts.
- **Vertical writing systems** — stand the whole diagram up as columns for Japanese tategaki, Chinese, or traditional Mongolian; characters stack or the line rotates, chosen per line, so a horizontal translation stays readable beside it.
- **Tokenization control** — choose how text splits into word boxes (split characters, a join marker for fixed expressions, optional punctuation handling).
- **Per-line typography** — font, size, spacing, and direction per line; custom and Google Fonts.
- **Exports** — PNG, SVG, PDF, and a self-contained HTML file; exports match the preview, including custom-font shaping.
- **Shareable URLs** — the whole alignment (text, links, and visual settings) is encoded in a `?data=` link.

## Examples

Browse ready-made examples — bilingual pairs, Turkish interlinear with IPA, RTL scripts, Tagalog compounds, Japanese–Chinese–English word order, and more interlinear glosses — at **[aligner.tinygods.dev/examples](https://aligner.tinygods.dev/examples)**. Open any one in the editor to adapt it.
Browse ready-made examples — bilingual pairs, Turkish interlinear with IPA, RTL scripts, vertical Japanese and traditional Mongolian, Tagalog compounds, Japanese–Chinese–English word order, and more interlinear glosses — at **[aligner.tinygods.dev/examples](https://aligner.tinygods.dev/examples)**. Open any one in the editor to adapt it.

![Turkish interlinear preview with glosses, IPA, and connectors](bitext/static/screenshots/preview-turkish-example.png)

Expand Down
2 changes: 2 additions & 0 deletions bitext/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ OBJECT_STORAGE_CDN_ENDPOINT=
# Render script (optional)
# PREVIEW_URL=http://127.0.0.1:4173
# PREVIEW_PORT=4173
# Use a system Chromium instead of Playwright's download (needed on NixOS/Guix)
# CHROMIUM_PATH=/usr/bin/chromium
12 changes: 6 additions & 6 deletions bitext/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions bitext/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ npm install
npx playwright install chromium
```

Playwright's bundled Chromium is dynamically linked and will not start on distros that do not
provide its libraries (NixOS, Guix report `libglib-2.0.so.0: cannot open shared object file`).
Point the render script at a system browser instead:

```bash
CHROMIUM_PATH=$(which chromium) npm run examples:render
```

Copy `.env.example` to `.env` in the `bitext/` folder and fill in object storage credentials.

## Render + upload
Expand Down
9 changes: 8 additions & 1 deletion bitext/scripts/render-example-previews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ function resolveRenderFonts(): { css: string; skip: Set<string> } {
return { css: faces.join('\n'), skip };
}

/** Path to a system Chromium; unset means Playwright's own download. */
const CHROMIUM_EXECUTABLE = process.env.CHROMIUM_PATH?.trim() || undefined;

const PREVIEW_PORT = Number(process.env.PREVIEW_PORT ?? 4173);
const PREVIEW_URL = (process.env.PREVIEW_URL ?? `http://127.0.0.1:${PREVIEW_PORT}`).replace(
/\/$/,
Expand Down Expand Up @@ -228,7 +231,11 @@ async function main(): Promise<void> {
const { css: renderFontCss, skip: skipSlugs } = resolveRenderFonts();
const renderedSlugs: string[] = [];

const browser = await chromium.launch();
// Playwright's bundled Chromium is dynamically linked against libs some distros (NixOS,
// Guix) do not provide, so allow pointing at a system browser instead.
const browser = await chromium.launch(
CHROMIUM_EXECUTABLE ? { executablePath: CHROMIUM_EXECUTABLE } : {}
);
try {
const page = await browser.newPage({
viewport: { width: EXAMPLE_RENDER_WIDTH + 80, height: 1200 },
Expand Down
62 changes: 62 additions & 0 deletions bitext/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,45 @@ html {
align-items: stretch;
}

/* Column mode: lines sit side by side, first line leftmost — the same "first is at the start of
the stack" rule as rows. Which script ends up on which side is set by line order. */
/* Columns stretch to the tallest one rather than to a percentage height, so the same rules work
whether the frame is bounded (editor) or hugs its content (readonly embed, headless render). */
.preview-stack--columns {
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
justify-content: center;
}

/* Column mode grows sideways, so the block axis needs a bound for auto-fit to shrink a long
sentence into. Only while editing: a readonly embed has no viewport to fit, and forcing one
there would screenshot mostly blank canvas. */
.preview-frame[data-aligner-axis='columns']:not([data-aligner-readonly]) {
height: min(70vh, 44rem);
}

.preview-frame[data-aligner-axis='columns']:not([data-aligner-readonly]) .preview-zoom,
.preview-frame[data-aligner-axis='columns']:not([data-aligner-readonly]) .preview-stack--columns {
height: 100%;
}

.preview-stack--columns .preview-token-line {
min-height: 0;
}

.preview-stack--columns .preview-gloss-wrap {
width: auto;
min-height: 0;
align-items: center;
}

/* The credit is a flex item of the stack, so in a row it would sit beside the diagram. Give it a
full basis and it wraps onto its own line underneath. */
.preview-stack--columns .preview-frame__attribution {
flex-basis: 100%;
}

.preview-frame__attribution {
position: relative;
z-index: 3;
Expand Down Expand Up @@ -378,6 +417,29 @@ path.link-path {
flex-wrap: wrap;
}

/* Column mode: setting the row itself vertically makes flex direction and the logical
`margin-inline-start` between tokens map to the block axis with no extra branching.
Baseline alignment is dropped because a column may mix upright and vertically set tokens. */
.token-row--columns {
writing-mode: vertical-rl;
align-items: center;
}

/* Glyph setting per line. `upright` is explicit so it resets the inherited vertical row above. */
.token-view--upright {
writing-mode: horizontal-tb;
}

.token-view--vertical {
writing-mode: vertical-rl;
text-orientation: upright;
}

.token-view--sideways {
writing-mode: vertical-rl;
text-orientation: sideways;
}

.token-view {
display: inline-flex;
align-items: baseline;
Expand Down
16 changes: 16 additions & 0 deletions bitext/src/lib/components/editor/LineSettingsForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { TrashBinOutline } from 'flowbite-svelte-icons';
import { Label, Range } from 'flowbite-svelte';
import type { LineV2 } from '$lib/serialization/schema.js';
import type { TextOrientation } from '$lib/types/layout.js';
import { GOOGLE_FONT_OPTIONS } from '$lib/fonts/google-fonts.js';
import {
MAX_TEXT_SIZE_PX,
Expand Down Expand Up @@ -258,6 +259,21 @@
/>
<span>Right-to-left row</span>
</Label>
<div class="min-w-0">
<Label class="mb-1 block text-xs text-gray-600 dark:text-gray-400">Text setting</Label>
<select
class={sel}
value={line.textOrientation ?? 'upright'}
onchange={(e) =>
projectStore.updateLineStyle(line.id, {
textOrientation: (e.currentTarget as HTMLSelectElement).value as TextOrientation
})}
>
<option value="upright">Upright (horizontal words)</option>
<option value="vertical">Vertical (stacked characters)</option>
<option value="sideways">Sideways (rotated line)</option>
</select>
</div>
{#if nextLine}
<Label
class="inline-flex min-w-0 cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
Expand Down
37 changes: 31 additions & 6 deletions bitext/src/lib/components/preview/AlignmentPreview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
const connections = $derived(projectStore.connections);
const lineIds = $derived(projectStore.lines.map((l) => l.id));

const axis = $derived(settingsStore.settings.layoutAxis);
const columns = $derived(axis === 'columns');

// --- Auto-fit: shrink font/gap so a line never wraps to a second row ---
const autoFit = $derived(settingsStore.settings.autoFit);
const autoFitVariance = $derived(settingsStore.settings.autoFitVariance);
Expand Down Expand Up @@ -105,6 +108,7 @@
void settingsStore.settings.showNumbers;
void settingsStore.settings.tokenSplitChars;
void settingsStore.settings.style;
void settingsStore.settings.layoutAxis;
void writesExportLayout;
void layoutExportStore.layoutRemeasureTick;

Expand All @@ -121,14 +125,23 @@

function recompute() {
if (!rootEl) return;
const rows: { lineId: string; width: number; effScale: number }[] = [];
// Auto-fit works along the flow axis: row width in `rows`, column height in `columns`.
const vertical = settingsStore.settings.layoutAxis === 'columns';
const rows: { lineId: string; extent: number; effScale: number }[] = [];
let avail = Infinity;
rootEl.querySelectorAll<HTMLElement>('.token-row[data-line]').forEach((row) => {
const id = row.dataset.line;
if (!id) return;
const a = row.parentElement?.clientWidth ?? row.clientWidth;
const parent = row.parentElement;
const a = vertical
? (parent?.clientHeight ?? row.clientHeight)
: (parent?.clientWidth ?? row.clientWidth);
if (a > 0) avail = Math.min(avail, a);
rows.push({ lineId: id, width: row.scrollWidth, effScale: effScale[id] ?? 1 });
rows.push({
lineId: id,
extent: vertical ? row.scrollHeight : row.scrollWidth,
effScale: effScale[id] ?? 1
});
});
if (!rows.length || !Number.isFinite(avail)) return;
const next = computeAutoFitScales(rows, avail * 0.98, autoFitVariance);
Expand Down Expand Up @@ -284,6 +297,8 @@
class:preview-frame--light={!previewDark}
class:preview-frame--dark={previewDark}
data-aligner-style={style.id}
data-aligner-axis={axis}
data-aligner-readonly={readonly ? '' : undefined}
data-autofit={autoFit ? 'on' : 'off'}
style:background={plainCanvas ? undefined : canvas.previewBackground}
style:color={plainCanvas ? undefined : canvas.textColor}
Expand Down Expand Up @@ -321,10 +336,12 @@
</div>
{/if}
<div class="preview-zoom" bind:this={zoomEl} style:transform={zoomTransform}>
<div class="preview-stack">
<div class="preview-stack" class:preview-stack--columns={columns}>
{#if !readonly}
<div
class="mb-1 flex justify-center {hideChrome ? chromeHiddenLayer : ''}"
class="flex justify-center {columns ? 'me-1 items-center' : 'mb-1'} {hideChrome
? chromeHiddenLayer
: ''}"
aria-hidden={hideChrome ? true : undefined}
>
<button
Expand All @@ -347,6 +364,7 @@
<div
data-line={line.id}
class="preview-token-line flex items-center gap-3 transition-opacity duration-150"
class:flex-col={columns}
class:opacity-[0.34]={!readonly && rowDimmed}
style:font-family={lineFontCss(line)}
>
Expand All @@ -360,6 +378,7 @@
index={li}
total={projectStore.lines.length}
{previewDark}
{axis}
/>
</div>
{/if}
Expand All @@ -372,6 +391,8 @@
showNumbers={settingsStore.settings.showNumbers}
interactive={!readonly}
rtl={Boolean(line.rtl)}
{axis}
orientation={line.textOrientation ?? 'upright'}
/>
</div>
{#if !readonly}
Expand All @@ -396,13 +417,16 @@
upperLineId={line.id}
lowerLineId={lowerLine.id}
{previewDark}
{axis}
showControls={!readonly && !hideChrome}
/>
{/if}
{/each}
{#if !readonly}
<div
class="mt-1 flex justify-center {hideChrome ? chromeHiddenLayer : ''}"
class="flex justify-center {columns ? 'ms-1 items-center' : 'mt-1'} {hideChrome
? chromeHiddenLayer
: ''}"
aria-hidden={hideChrome ? true : undefined}
>
<button
Expand Down Expand Up @@ -435,6 +459,7 @@
{connections}
{writesExportLayout}
{thicknessScale}
{axis}
zoom={zoom.z}
showPins={!readonly && !hideChrome}
/>
Expand Down
Loading