feat(studio): text effects, fit to frame and 24 lockups in a Text styles shelf

- A text can be cased (capitals, lower case, small caps), placed at the top,
  middle or foot of its frame, outlined, given a hard shadow or a marker band,
  bent on an arc or round a circle, filled with a gallery photo or left
  hollow, and shrunk to fit its frame. Every effect prints as vector: copies
  and strokes for HTML type, textPath and a clip over an <image> for SVG.
- Fitting is solved when the text is edited, in the Studio and in the edit
  engine, and stored; the chosen size is kept as the ceiling.
- Text sizes reach 400 pt and tracking 1.5 em.
- The Elements panel opens on a Text tab: the plain styles and 24 lockups
  (titles, badges and stamps, chapter marks, quotes, captions, places), each
  drawn with the book's own title and placed as one group.
- The assistant sets every effect through style and add, places a lockup with
  add_lockup, reads the effects in describe_book_page and gets two new
  findings (curve_overflow, effect_heavy) beside photo-fill and band checks.
- Book format revision 6.
This commit is contained in:
Maurice
2026-10-23 21:43:09 +02:00
parent 82b8eb1067
commit 75d0593198
125 changed files with 6469 additions and 51 deletions
@@ -60,6 +60,8 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
};
/** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -9,6 +9,8 @@ import { HOLED_SHAPES } from './shapes'
import { hasEdge, shapeOutline } from './shapeOutline'
import { LineView } from './LineView'
import { TravelElementView } from './TravelElements'
import { TextEffectsView } from './TextEffectsView'
import { hasTextEffects } from './textEffects'
import { PageNumbers } from './PageNumbers'
import { PaintedShapeView } from './paint/PaintedShape'
import { SpreadPaint } from './paint/paintRender'
@@ -189,6 +191,9 @@ export function ElementView({
return <TravelElementView el={el} frameStyle={frameStyle(el)} big={big} />
}
// Outlines, shadows, bands, curves and fills draw in their own file; a plain text stays here, as it always drew.
if (hasTextEffects(el)) return <TextEffectsView el={el} frameStyle={frameStyle(el)} big={big} />
return (
<div
style={{
@@ -9,6 +9,8 @@ import { StudioLineShelf } from './StudioLineShelf'
import { PanelHead } from './StudioPanelHead'
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
import '../../styles/studio-elements.css'
import { StudioTextStyles } from './StudioTextStyles'
import type { ShelfSource } from './textStylesModel'
/**
* Everything you can add that does not come from the journey: type, shapes,
@@ -97,8 +99,55 @@ function IconCell({ name, onPick }: { name: string; onPick: (name: string) => vo
)
}
export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: string) => string }) {
const [tab, setTab] = useState<'shapes' | 'frames' | 'grids' | 'icons'>('shapes')
/** A plain text style: size, weight, its sample words and anything else it sets. */
type AddText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra?: Partial<BookElement>) => void
/** The type tab: the four plain styles, then the lockups of the Text styles shelf. */
function TextTab({ page, source, t, addText }: {
page: BookPageSetup
source: ShelfSource
t: (k: string) => string
addText: AddText
}) {
return (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<StudioTextStyles page={page} source={source} t={t} />
</>
)
}
export function StudioElementsPanel({ page, t, source }: {
page: BookPageSetup
t: (k: string) => string
/** The journal, for the year and the point a lockup is filled with. */
source: ShelfSource
}) {
const [tab, setTab] = useState<'text' | 'shapes' | 'frames' | 'grids' | 'icons'>('text')
const [iconQuery, setIconQuery] = useState('')
/*
* How much of the library is on screen.
@@ -223,6 +272,9 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
<PanelHead label={t('journey.studio.elements')} />
<div className="st-tabs is-fluid">
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
{t('journey.studio.text')}
</button>
<button type="button" className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
{t('journey.studio.shapes')}
</button>
@@ -238,33 +290,14 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
</div>
<div className="st-panel-scroll">
{/*
Type first: the plain styles, then the lockups, set titles and marks
drawn with the book's own words (StudioTextStyles.tsx).
*/}
{tab === 'text' && <TextTab page={page} source={source} t={t} addText={addText} />}
{tab === 'shapes' && (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button type="button" className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button type="button" className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button type="button"
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.lines')}</div>
<div className="st-shape-grid">
@@ -13,13 +13,14 @@ import { StudioStickerInspector } from './StudioStickerInspector'
import { Swatches } from './StudioSwatches'
import { StudioBackgroundSection } from './StudioBackgroundSection'
import { StudioShapePaint } from './StudioShapePaint'
import { formatBookCoords, withThemeLinks } from '@trek/shared'
import { MAX_BOOK_TEXT_SIZE, formatBookCoords, withThemeLinks } from '@trek/shared'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { italicTitle } from './fontPicker'
import { PhotoCropExtras, PhotoLookSections } from './photo/StudioPhotoLook'
import { StudioTextEffects } from './StudioTextEffects'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
import { StudioArrangeSection } from './StudioArrangeSection'
@@ -368,7 +369,7 @@ export function StudioInspector({
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={200} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={MAX_BOOK_TEXT_SIZE} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
</div>
<div style={{ marginTop: 10 }}>
@@ -381,7 +382,7 @@ export function StudioInspector({
label={t('journey.studio.tracking')}
value={el.tracking}
min={-0.2}
max={1}
max={1.5}
step={0.01}
unit="em"
onChange={v => set({ tracking: v })}
@@ -396,6 +397,8 @@ export function StudioInspector({
onLink={(token, color) => set({ color, token })} onUnlink={() => set({ token: undefined })} />
</Line>
</Section>
<StudioTextEffects el={el} set={set} live={live} photos={source.photos} spread={spread} t={t} />
</>
)}
@@ -739,7 +742,7 @@ export function StudioInspector({
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
* image answers the question by showing it.
*/
function FocalPad({
export function FocalPad({
photoId, x, y, onDrag, onCommit, hint,
}: {
photoId: number
@@ -151,7 +151,7 @@ export function StudioSidebar({
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
providers={providers} onBrowseProvider={onBrowseProvider} />
)}
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
{section === 'elements' && <StudioElementsPanel page={page} t={t} source={source} />}
{section === 'stickers' && <StudioStickersPanel page={page} t={t} />}
{section === 'travel' && (
<StudioTravelPanel
@@ -0,0 +1,177 @@
import { describe, expect, it, vi } from 'vitest';
import type { BookSpread, BookTextElement } from '@trek/shared';
import { bookTextElementSchema } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { StudioTextEffects } from './StudioTextEffects';
/** The text inspector's effects: each switch arrives looking right, and each control sets one field. */
const t = (k: string) => k;
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({
id: 't1',
kind: 'text',
frame: { x: 0, y: 0, w: 100, h: 40 },
text: 'Lisbon',
size: 72,
color: '#1a1a1a',
...over,
});
const spread = {
id: 'sp',
role: 'inner',
background: null,
parked: [],
entryId: null,
elements: [{ id: 'p1', kind: 'photo', photoId: 4 }],
} as unknown as BookSpread;
function mount(el: BookTextElement, photos = [{ photoId: 4 }, { photoId: 5 }]) {
const set = vi.fn();
const live = vi.fn();
const view = render(<StudioTextEffects el={el} set={set} live={live} photos={photos} spread={spread} t={t} />);
return { set, live, ...view };
}
/** The switch beside a label: the app's toggle, a button named by the label it carries. */
const toggle = (label: string) => screen.getByRole('button', { name: label });
/** A number field, named by its label, followed by its value and unit. */
const num = (label: string) => screen.getByRole('spinbutton', { name: (name) => name.startsWith(label) });
describe('case and placement', () => {
it('sets the case and where the lines sit', () => {
const { set } = mount(text());
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.caseSmall' }));
expect(set).toHaveBeenCalledWith({ transform: 'smallcaps' });
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.middle' }));
expect(set).toHaveBeenCalledWith({ vAlign: 'middle' });
});
it('shrinks to fit, and gives the chosen size back when fitting is turned off', () => {
const on = mount(text());
fireEvent.click(toggle('journey.studio.fx.fit'));
expect(on.set).toHaveBeenCalledWith({ fit: 'shrink' });
on.unmount();
const off = mount(text({ fit: 'shrink', size: 40, fitMax: 72 }));
expect(screen.getByText('journey.studio.fx.fitHint')).toBeTruthy();
fireEvent.click(toggle('journey.studio.fx.fit'));
expect(off.set).toHaveBeenCalledWith({ fit: 'none', size: 72 });
});
});
describe('effects', () => {
it('folds the effects away while a text has none, and opens them when it has one', () => {
mount(text());
expect(screen.queryByRole('button', { name: 'journey.studio.stroke' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /journey.studio.fx.effects/ }));
expect(toggle('journey.studio.stroke')).toBeTruthy();
});
it('switches an outline, a shadow and a band on in a colour that stands out, and off again', () => {
const { set } = mount(
text({
curve: null,
outline: null,
shadow: null,
highlight: null,
fillPaint: { type: 'solid', color: '#1a1a1a', alpha: 1 },
})
);
fireEvent.click(toggle('journey.studio.stroke'));
expect(set).toHaveBeenCalledWith({ outline: { color: '#ffffff', width: 1.27 } });
fireEvent.click(toggle('journey.studio.fx.shadow'));
expect(set).toHaveBeenCalledWith({ shadow: { dx: 2.03, dy: 2.03, color: '#ffffff', alpha: 1 } });
fireEvent.click(toggle('journey.studio.fx.band'));
expect(set).toHaveBeenCalledWith({ highlight: { color: '#ffffff', alpha: 1, padX: 6.35, padY: 1.52, radius: 0 } });
});
it('edits an outline that is on, and takes hollow letters back to colour with it', () => {
const outline = { color: '#c2410c', width: 0.5 };
const { set } = mount(text({ outline, fillPaint: { type: 'solid', color: '#1a1a1a', alpha: 0 } }));
const width = num('journey.studio.strokeWidth');
fireEvent.change(width, { target: { value: '1.5' } });
fireEvent.blur(width);
expect(set).toHaveBeenCalledWith({ outline: { ...outline, width: 1.5 } });
fireEvent.click(toggle('journey.studio.stroke'));
expect(set).toHaveBeenCalledWith({ outline: null, fillPaint: null });
});
it('edits a shadow and a band by their numbers', () => {
const shadow = { dx: 1, dy: 1, color: '#111111', alpha: 1 };
const highlight = { color: '#111111', alpha: 1, padX: 1, padY: 0.3, radius: 0 };
const { set } = mount(text({ shadow, highlight }));
const across = num('journey.studio.fx.across');
fireEvent.change(across, { target: { value: '3' } });
fireEvent.blur(across);
expect(set).toHaveBeenCalledWith({ shadow: { ...shadow, dx: 3 } });
const opacity = num('journey.studio.opacity');
fireEvent.change(opacity, { target: { value: '40' } });
fireEvent.blur(opacity);
expect(set).toHaveBeenCalledWith({ shadow: { ...shadow, alpha: 0.4 } });
const pad = num('journey.studio.fx.padDown');
fireEvent.change(pad, { target: { value: '2' } });
fireEvent.blur(pad);
expect(set).toHaveBeenCalledWith({ highlight: { ...highlight, padY: 2 } });
});
it('bends the words on an arc or a circle and straightens them again', () => {
const arc = mount(text({ outline: { color: '#111111', width: 0.2 } }));
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.arc' }));
expect(arc.set).toHaveBeenCalledWith({ curve: { type: 'arc', bend: 40, start: 0 } });
arc.unmount();
const curve = { type: 'circle' as const, bend: 40, start: 0 };
const circle = mount(text({ curve }));
const start = num('journey.studio.fx.start');
fireEvent.change(start, { target: { value: '90' } });
fireEvent.blur(start);
expect(circle.set).toHaveBeenCalledWith({ curve: { ...curve, start: 90 } });
expect(screen.getByText('journey.studio.fx.drawnHint')).toBeTruthy();
expect(screen.getByText('journey.studio.fx.bandDrawn')).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.straight' }));
expect(circle.set).toHaveBeenCalledWith({ curve: null });
});
});
describe('the fill of the letters', () => {
it('leaves hollow letters with an outline in their own colour', () => {
const { set } = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' }, color: '#0f766e' }));
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.fx.hollow' }));
expect(set).toHaveBeenCalledWith({
fillPaint: { type: 'solid', color: '#0f766e', alpha: 0 },
outline: { color: '#0f766e', width: 1.27 },
});
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.colour' }));
expect(set).toHaveBeenCalledWith({ fillPaint: null });
});
it('fills the letters with a photo of the page first, then lets another be picked, framed and zoomed', () => {
const first = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }));
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.kind.photo' }));
expect(first.set).toHaveBeenCalledWith({
fillPaint: { type: 'photo', photoId: 4, focalX: 0.5, focalY: 0.5, zoom: 1 },
});
first.unmount();
const fill = { type: 'photo' as const, photoId: 4, focalX: 0.5, focalY: 0.5, zoom: 1 };
const { set } = mount(text({ fillPaint: fill }));
const photos = screen.getAllByRole('radio', { name: 'journey.studio.fx.pickPhoto' });
expect(photos).toHaveLength(2);
expect(photos[0]?.getAttribute('aria-checked')).toBe('true');
fireEvent.click(photos[1] as HTMLElement);
expect(set).toHaveBeenCalledWith({ fillPaint: { ...fill, photoId: 5 } });
const zoom = num('journey.studio.fx.zoom');
fireEvent.change(zoom, { target: { value: '150' } });
fireEvent.blur(zoom);
expect(set).toHaveBeenCalledWith({ fillPaint: { ...fill, zoom: 1.5 } });
expect(screen.getByText('journey.studio.fx.focalHint')).toBeTruthy();
});
it('says so, and offers no photo fill, when the journey has no photos', () => {
mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }), []);
expect(screen.getByText('journey.studio.fx.noPhotos')).toBeTruthy();
expect((screen.getByRole('radio', { name: 'journey.studio.kind.photo' }) as HTMLButtonElement).disabled).toBe(true);
});
});
@@ -0,0 +1,369 @@
import {
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignVerticalJustifyStart,
CaseLower,
CaseSensitive,
CaseUpper,
} from 'lucide-react';
import type { BookElement, BookSpread, BookTextElement } from '@trek/shared';
import { bookTextIsDrawn } from '@trek/shared';
import '../../styles/studio-text.css';
import { photoSrc } from './bookRender';
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
import { FocalPad } from './StudioInspector';
import { Swatches } from './StudioSwatches';
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
/**
* The text inspector's effects: how the letters are cased and where the lines
* sit, whether the text shrinks to its frame, and the outline, hard shadow,
* marker band, curve and fill (book-text-effects.schema.ts in shared).
*
* Every effect is a switch that arrives already looking right: an outline in
* the colour that stands out from the letters at a twentieth of their height,
* a shadow a twelfth of it down and across, a band with a quarter em of room.
* A first press that shows something good is the difference between a feature
* people use and a row of switches nobody turns on twice.
*/
type Patch = Partial<BookTextElement>;
type SetPatch = (patch: Partial<BookElement>) => void;
interface Props {
el: BookTextElement;
/** One undo step. */
set: SetPatch;
/** A change inside a drag, as the focal pad makes. */
live: SetPatch;
/** The journey's photos, for filling the letters with one. */
photos: readonly { photoId: number }[];
spread: BookSpread | undefined;
t: (k: string) => string;
}
/** How many photos the fill offers at once: two rows of the panel's thumbnails. */
const FILL_PHOTOS = 12;
function CaseAndPlace({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
return (
<Section label={t('journey.studio.fx.placement')}>
<Line label={t('journey.studio.fx.case')}>
<Choice
value={el.transform ?? 'none'}
ariaLabel={t('journey.studio.fx.case')}
options={[
{ value: 'none' as const, label: <CaseSensitive size={15} />, title: t('journey.studio.fx.caseNone') },
{ value: 'upper' as const, label: <CaseUpper size={15} />, title: t('journey.studio.fx.caseUpper') },
{ value: 'lower' as const, label: <CaseLower size={15} />, title: t('journey.studio.fx.caseLower') },
{ value: 'smallcaps' as const, label: t('journey.studio.fx.caseSmall') },
]}
onPick={(transform) => set({ transform } as Patch)}
/>
</Line>
<Line label={t('journey.studio.fx.vertical')}>
<Choice
value={el.vAlign ?? 'top'}
ariaLabel={t('journey.studio.fx.vertical')}
options={[
{
value: 'top' as const,
label: <AlignVerticalJustifyStart size={15} />,
title: t('journey.studio.fx.top'),
},
{
value: 'middle' as const,
label: <AlignVerticalJustifyCenter size={15} />,
title: t('journey.studio.fx.middle'),
},
{
value: 'bottom' as const,
label: <AlignVerticalJustifyEnd size={15} />,
title: t('journey.studio.fx.bottom'),
},
]}
onPick={(vAlign) => set({ vAlign } as Patch)}
/>
</Line>
<Switch
label={t('journey.studio.fx.fit')}
on={el.fit === 'shrink'}
onToggle={() =>
set((el.fit === 'shrink' ? { fit: 'none', size: el.fitMax ?? el.size } : { fit: 'shrink' }) as Patch)
}
/>
{el.fit === 'shrink' && <p className="st-hint st-fx-hint">{t('journey.studio.fx.fitHint')}</p>}
</Section>
);
}
function OutlineControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
const outline = el.outline;
return (
<div className="st-fx-group">
<Switch
label={t('journey.studio.stroke')}
on={!!outline}
onToggle={() =>
set({
outline: outline ? null : defaultOutline(el),
...(outline && isHollow(el) ? { fillPaint: null } : {}),
} as Patch)
}
/>
{outline && (
<div className="st-fx-body">
<NumField
label={t('journey.studio.strokeWidth')}
value={outline.width}
min={0.05}
max={6}
step={0.05}
unit="mm"
onChange={(width) => set({ outline: { ...outline, width } } as Patch)}
/>
<Swatches value={outline.color} onPick={(color) => set({ outline: { ...outline, color } } as Patch)} />
</div>
)}
</div>
);
}
function ShadowControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
const shadow = el.shadow;
return (
<div className="st-fx-group">
<Switch
label={t('journey.studio.fx.shadow')}
on={!!shadow}
onToggle={() => set({ shadow: shadow ? null : defaultShadow(el) } as Patch)}
/>
{shadow && (
<div className="st-fx-body">
<div className="st-grid2">
<NumField
label={t('journey.studio.fx.across')}
value={shadow.dx}
min={-30}
max={30}
step={0.1}
unit="mm"
onChange={(dx) => set({ shadow: { ...shadow, dx } } as Patch)}
/>
<NumField
label={t('journey.studio.fx.down')}
value={shadow.dy}
min={-30}
max={30}
step={0.1}
unit="mm"
onChange={(dy) => set({ shadow: { ...shadow, dy } } as Patch)}
/>
</div>
<NumField
label={t('journey.studio.opacity')}
value={Math.round(shadow.alpha * 100)}
min={0}
max={100}
step={5}
unit="%"
onChange={(v) => set({ shadow: { ...shadow, alpha: v / 100 } } as Patch)}
/>
<Swatches value={shadow.color} onPick={(color) => set({ shadow: { ...shadow, color } } as Patch)} />
</div>
)}
</div>
);
}
function BandControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
const band = el.highlight;
if (bookTextIsDrawn(el)) {
return <p className="st-hint st-fx-hint">{t('journey.studio.fx.bandDrawn')}</p>;
}
return (
<div className="st-fx-group">
<Switch
label={t('journey.studio.fx.band')}
on={!!band}
onToggle={() => set({ highlight: band ? null : defaultBand(el) } as Patch)}
/>
{band && (
<div className="st-fx-body">
<div className="st-grid2">
<NumField
label={t('journey.studio.fx.padAcross')}
value={band.padX}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={(padX) => set({ highlight: { ...band, padX } } as Patch)}
/>
<NumField
label={t('journey.studio.fx.padDown')}
value={band.padY}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={(padY) => set({ highlight: { ...band, padY } } as Patch)}
/>
</div>
<NumField
label={t('journey.studio.radius')}
value={band.radius}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={(radius) => set({ highlight: { ...band, radius } } as Patch)}
/>
<Swatches value={band.color} onPick={(color) => set({ highlight: { ...band, color } } as Patch)} />
</div>
)}
</div>
);
}
function CurveControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
const curve = el.curve;
return (
<div className="st-fx-group">
<Line label={t('journey.studio.fx.curve')}>
<Choice
value={curve?.type ?? 'none'}
ariaLabel={t('journey.studio.fx.curve')}
options={[
{ value: 'none' as const, label: t('journey.studio.fx.straight') },
{ value: 'arc' as const, label: t('journey.studio.fx.arc') },
{ value: 'circle' as const, label: t('journey.studio.fx.circle') },
]}
onPick={(type) =>
set({
curve: type === 'none' ? null : { type, bend: curve?.bend ?? 40, start: curve?.start ?? 0 },
} as Patch)
}
/>
</Line>
{curve?.type === 'arc' && (
<NumField
label={t('journey.studio.fx.bend')}
value={curve.bend}
min={-100}
max={100}
step={5}
onChange={(bend) => set({ curve: { ...curve, bend } } as Patch)}
/>
)}
{curve?.type === 'circle' && (
<NumField
label={t('journey.studio.fx.start')}
value={curve.start}
min={-180}
max={180}
step={5}
unit="°"
onChange={(start) => set({ curve: { ...curve, start } } as Patch)}
/>
)}
</div>
);
}
type FillKind = 'colour' | 'hollow' | 'photo';
function fillKindOf(el: BookTextElement): FillKind {
if (el.fillPaint?.type === 'photo') return 'photo';
return isHollow(el) ? 'hollow' : 'colour';
}
function FillControls({ el, set, live, photos, spread, t }: Props) {
const kind = fillKindOf(el);
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
const pick = (next: FillKind) => {
if (next === 'colour') return set({ fillPaint: null } as Patch);
if (next === 'hollow') {
return set({
fillPaint: { type: 'solid', color: el.color, alpha: 0 },
// Hollow letters are their outline: one appears with them if there was none.
outline: el.outline ?? { ...defaultOutline(el), color: el.color },
} as Patch);
}
const photoId = firstFillPhoto(spread, photos);
if (photoId != null) set({ fillPaint: { type: 'photo', photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } } as Patch);
};
return (
<div className="st-fx-group">
<Line label={t('journey.studio.fx.letters')}>
<Choice
value={kind}
ariaLabel={t('journey.studio.fx.letters')}
options={[
{ value: 'colour' as const, label: t('journey.studio.colour') },
{ value: 'hollow' as const, label: t('journey.studio.fx.hollow') },
{ value: 'photo' as const, label: t('journey.studio.kind.photo'), disabled: !photos.length },
]}
onPick={pick}
/>
</Line>
{!photos.length && <p className="st-hint st-fx-hint">{t('journey.studio.fx.noPhotos')}</p>}
{photo && (
<div className="st-fx-body">
<div className="st-fx-photos" role="radiogroup" aria-label={t('journey.studio.fx.pickPhoto')}>
{photos.slice(0, FILL_PHOTOS).map((p) => (
<button
type="button"
key={p.photoId}
role="radio"
aria-checked={p.photoId === photo.photoId}
aria-label={t('journey.studio.fx.pickPhoto')}
className={`st-fx-photo ${p.photoId === photo.photoId ? 'is-on' : ''}`}
onClick={() => set({ fillPaint: { ...photo, photoId: p.photoId } } as Patch)}
>
<img src={photoSrc(p.photoId, false)} alt="" draggable={false} />
</button>
))}
</div>
<FocalPad
photoId={photo.photoId}
x={photo.focalX}
y={photo.focalY}
onDrag={(focalX, focalY) => live({ fillPaint: { ...photo, focalX, focalY } } as Patch)}
onCommit={(focalX, focalY) => set({ fillPaint: { ...photo, focalX, focalY } } as Patch)}
hint={t('journey.studio.fx.focalHint')}
/>
<NumField
label={t('journey.studio.fx.zoom')}
value={Math.round(photo.zoom * 100)}
min={100}
max={400}
step={10}
unit="%"
onChange={(v) => set({ fillPaint: { ...photo, zoom: v / 100 } } as Patch)}
/>
</div>
)}
</div>
);
}
export function StudioTextEffects(props: Props) {
const { el, set, t } = props;
const any = !!(el.outline || el.shadow || el.highlight || el.curve || el.fillPaint);
return (
<>
<CaseAndPlace el={el} set={set} t={t} />
<Section label={t('journey.studio.fx.effects')} defaultOpen={any}>
<OutlineControls el={el} set={set} t={t} />
<ShadowControls el={el} set={set} t={t} />
<BandControls el={el} set={set} t={t} />
<CurveControls el={el} set={set} t={t} />
<FillControls {...props} />
{bookTextIsDrawn(el) && <p className="st-hint st-fx-hint">{t('journey.studio.fx.drawnHint')}</p>}
</Section>
</>
);
}
@@ -0,0 +1,98 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { BookDocument, BookTextElement } from '@trek/shared';
import { BOOK_LOCKUPS, bookPageSetupSchema, normalizeBookDocument } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { StudioElementsPanel } from './StudioElementsPanel';
import { StudioTextStyles } from './StudioTextStyles';
/**
* The Text styles shelf: every lockup drawn with the book's own title, placed
* on the open page with one click and selected as one set, its texts fitted.
*/
const t = (k: string) => k;
const page = bookPageSetupSchema.parse({});
const source = { entries: [{ date: '2025-08-12', lat: null, lng: null }], photos: [{ photoId: 3 }] };
const book = (): BookDocument =>
normalizeBookDocument({
version: 1,
title: 'Porto',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [{ id: 'sp1', role: 'inner', entryId: null, background: null, elements: [], parked: [] }],
});
/* jsdom lays nothing out; the tiles measure themselves to scale the page drawing into them. */
const size = Object.getOwnPropertyDescriptors(HTMLElement.prototype);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 120 });
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 90 });
});
afterAll(() => {
for (const key of ['offsetWidth', 'offsetHeight'] as const) {
const was = size[key];
if (was) Object.defineProperty(HTMLElement.prototype, key, was);
}
});
beforeEach(() => {
useStudioStore.getState().load(book());
});
const elements = () => useStudioStore.getState().doc?.spreads[0]?.elements ?? [];
describe('the Text styles shelf', () => {
it('draws every lockup in its group, named, with the book title in it', () => {
render(<StudioTextStyles page={page} source={source} t={t} />);
for (const group of ['titles', 'badges', 'chapters', 'quotes']) {
expect(screen.getByText(`journey.studio.lockupGroup.${group}`)).toBeTruthy();
}
for (const l of BOOK_LOCKUPS)
expect(screen.getByRole('button', { name: `journey.studio.lockup.${l.id}` })).toBeTruthy();
const poster = screen.getByRole('button', { name: 'journey.studio.lockup.poster-stack' });
expect(poster.textContent).toContain('Porto');
expect(poster.textContent).toContain('2025');
expect(screen.getByText('journey.studio.textStylesHint')).toBeTruthy();
});
it('places a lockup on the open page in one undo step, selected as one set, every text fitted', () => {
render(<StudioTextStyles page={page} source={source} t={t} />);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.lockup.circle-badge' }));
const placed = elements();
expect(placed.length).toBeGreaterThan(3);
expect(useStudioStore.getState().selection).toEqual(placed.map((e) => e.id));
const texts = placed.filter((e): e is BookTextElement => e.kind === 'text');
expect(texts.map((e) => e.text)).toContain('Porto');
expect(texts.every((e) => e.fit === 'shrink')).toBe(true);
for (const e of placed) expect(e.frame.x).toBeGreaterThan(210);
expect(useStudioStore.getState().canUndo()).toBe(true);
useStudioStore.getState().undo();
expect(elements()).toEqual([]);
});
it('opens the Elements panel on the type tab, the plain styles above the lockups', () => {
render(<StudioElementsPanel page={page} t={t} source={source} />);
expect(screen.getByRole('button', { name: 'journey.studio.styleTitle' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.shapes' }));
expect(screen.queryByRole('button', { name: 'journey.studio.lockup.postmark' })).toBeNull();
});
});
describe('a fitted text in the Studio', () => {
it('is solved again whenever the document changes, and grows back when its frame does', () => {
render(<StudioTextStyles page={page} source={source} t={t} />);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.lockup.minimal-caps' }));
const title = elements()[0] as BookTextElement;
const chosen = title.fitMax ?? title.size;
const store = useStudioStore.getState();
store.setFrame(0, title.id, { ...title.frame, w: 8 });
const squeezed = elements()[0] as BookTextElement;
expect(squeezed.size).toBeLessThan(chosen);
store.setFrame(0, title.id, title.frame);
expect((elements()[0] as BookTextElement).size).toBe(title.size);
});
});
@@ -0,0 +1,131 @@
import { useMemo } from 'react';
import type { BookLockup, BookLockupWords, BookPageSetup } from '@trek/shared';
import { addBookLockup, bookElementId } from '@trek/shared';
import { useElementSize } from '../../hooks/useElementSize';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-text.css';
import { ElementView } from './SpreadView';
import { firstFillPhoto } from './textEffects';
import {
SHELF_GROUPS,
type ShelfSource,
placeLockup,
previewLockup,
shelfLockups,
shelfWords,
} from './textStylesModel';
/**
* The Text styles shelf: the 24 lockups, each drawn with the book's own title
* the way it will print, placed with one click and selected together so the
* set moves as one.
*
* The tiles are the page renderer at a small scale, as the travel previews are,
* so a tile can never promise a look the page does not draw.
*/
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
const PX_PER_MM = 96 / 25.4;
function LockupTile({
lockup,
words,
photoId,
label,
onPick,
}: {
lockup: BookLockup;
words: BookLockupWords;
photoId: number | null;
label: string;
onPick: (lockup: BookLockup) => void;
}) {
const { ref, width, height } = useElementSize<HTMLButtonElement>();
const elements = useMemo(() => previewLockup(lockup, words, photoId), [lockup, words, photoId]);
const drawnW = 100 * PX_PER_MM;
const drawnH = lockup.height * PX_PER_MM;
const scale = width > 0 ? Math.min((width * 0.84) / drawnW, (height * 0.78) / drawnH) : 0;
const dx = (width - drawnW * scale) / 2;
const dy = (height - drawnH * scale) / 2;
return (
<button
type="button"
ref={ref}
className="st-lockup"
title={label}
aria-label={label}
onClick={() => onPick(lockup)}
>
{scale > 0 && (
<span
dir="ltr"
aria-hidden
className="st-lockup-art"
style={{
width: '100mm',
height: `${lockup.height}mm`,
transform: `translate(${dx}px, ${dy}px) scale(${scale})`,
}}
>
{elements.map((el) => (
<ElementView key={el.id} el={el} big={false} />
))}
</span>
)}
</button>
);
}
export function StudioTextStyles({
page,
source,
t,
}: {
page: BookPageSetup;
source: ShelfSource;
t: (k: string) => string;
}) {
const title = useStudioStore((s) => s.doc?.title ?? '');
const active = useStudioStore((s) => s.activeSpread);
const spread = useStudioStore((s) => s.doc?.spreads[s.activeSpread]);
const commit = useStudioStore((s) => s.commit);
const select = useStudioStore((s) => s.select);
const words = useMemo(() => shelfWords(title, source, t), [title, source, t]);
const photoId = firstFillPhoto(spread, source.photos);
const place = (lockup: BookLockup) => {
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
if (!els.length) return;
commit((d) => ({
...d,
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
}));
// One group, selected, so the set moves, turns and resizes as one straight away.
select(els.map((e) => e.id));
};
return (
<>
{SHELF_GROUPS.map((group) => (
<div className="st-section" key={group.id}>
<div className="st-section-label">{t(`journey.studio.lockupGroup.${group.id}`)}</div>
<div className="st-lockups">
{shelfLockups(group.lockups).map((lockup) => (
<LockupTile
key={lockup.id}
lockup={lockup}
words={words}
photoId={photoId}
label={t(`journey.studio.lockup.${lockup.id}`)}
onPick={place}
/>
))}
</div>
</div>
))}
<p className="st-hint st-fx-hint">{t('journey.studio.textStylesHint')}</p>
</>
);
}
@@ -0,0 +1,136 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { BookTextElement } from '@trek/shared';
import { bookTextElementSchema } from '@trek/shared';
import { render } from '../../../tests/helpers/render';
import { ElementView } from './SpreadView';
import { TextEffectsView } from './TextEffectsView';
/**
* Text effects on the page: every effect a vector layer, never a blur or a
* filter, a curve and a photo fill drawn as SVG with ids of their own, and a
* plain text left to the renderer it always had.
*/
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({
id: 't1',
kind: 'text',
frame: { x: 10, y: 20, w: 100, h: 40 },
text: 'Lisbon',
size: 36,
...over,
});
const frameStyle = { position: 'absolute' as const, width: '100mm', height: '40mm' };
describe('a text that still wraps', () => {
it('stacks a shadow, a band, an outline and the fill, laid out alike', () => {
const el = text({
outline: { color: '#ffffff', width: 0.5 },
shadow: { dx: 1, dy: 2, color: '#111111', alpha: 0.5 },
highlight: { color: '#c2410c', padX: 2, padY: 0.5, radius: 1 },
transform: 'upper',
vAlign: 'bottom',
});
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
const frame = container.firstElementChild as HTMLElement;
const layers = Array.from(frame.children) as HTMLElement[];
expect(layers).toHaveLength(4);
expect(layers.map((l) => l.getAttribute('aria-hidden'))).toEqual(['true', 'true', 'true', null]);
for (const layer of layers) expect(layer.textContent).toBe('Lisbon');
// The styles as React writes them, which is what the print renderer reads.
const html = renderToStaticMarkup(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
expect(html).toContain('overflow:visible');
expect(html).toContain('transform:translate(1mm, 2mm)');
expect(html).toContain('color:rgba(17, 17, 17, 0.5)');
expect(html).toContain('-webkit-text-stroke:1mm rgba(17, 17, 17, 0.5)');
expect(html).toContain('background:#c2410c');
expect(html).toContain('box-decoration-break:clone');
expect(html).toContain('-webkit-text-stroke:1mm #ffffff');
expect(html.match(/text-transform:uppercase/g)).toHaveLength(4);
expect(html.match(/justify-content:flex-end/g)).toHaveLength(4);
expect(html).not.toMatch(/filter|blur|background-clip/);
});
it('draws hollow letters as their outline alone', () => {
const el = text({
outline: { color: '#111111', width: 0.4 },
fillPaint: { type: 'solid', color: '#111111', alpha: 0 },
});
const html = renderToStaticMarkup(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
expect(html).toContain('color:transparent;-webkit-text-stroke:0.4mm #111111');
expect(html).toContain('color:rgba(17, 17, 17, 0)');
});
it('keeps a text with only a case or a vertical place clipped to its frame, as plain text is', () => {
const el = text({ transform: 'smallcaps' });
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
expect((container.firstElementChild as HTMLElement).children).toHaveLength(1);
const html = renderToStaticMarkup(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
expect(html).toContain('overflow:hidden');
expect(html).toContain('font-variant-caps:small-caps');
});
});
describe('a text drawn as SVG', () => {
it('lays a curved text on its path with textPath, the path in the millimetres of the frame', () => {
const el = text({
curve: { type: 'arc', bend: 40, start: 0 },
align: 'center',
shadow: { dx: 1, dy: 1, color: '#111111' },
});
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
const svg = container.querySelector('svg');
expect(svg?.getAttribute('viewBox')).toBe('0 0 100 40');
const path = svg?.querySelector('defs path');
const used = svg?.getElementsByTagName('textPath') ?? [];
expect(used).toHaveLength(2);
for (const tp of Array.from(used)) {
expect(tp.getAttribute('href')).toBe(`#${path?.getAttribute('id')}`);
expect(tp.getAttribute('startOffset')).toBe('50%');
expect(tp.textContent).toBe('Lisbon');
}
expect(svg?.querySelector('g')?.getAttribute('transform')).toBe('translate(1 1)');
});
it('fills the letters with the photo through a clip of the letters, waiting on an <image>', () => {
const el = text({ text: 'LIS\nBON', fillPaint: { type: 'photo', photoId: 12, focalX: 0.5, focalY: 0.5, zoom: 1 } });
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big />);
const image = container.querySelector('image');
expect(image?.getAttribute('href')).toBe('/api/photos/12/original');
const clip = container.getElementsByTagName('clipPath')[0];
expect(image?.getAttribute('clip-path')).toBe(`url(#${clip?.getAttribute('id')})`);
expect(clip?.getAttribute('clipPathUnits')).toBe('userSpaceOnUse');
expect(Array.from(clip?.querySelectorAll('tspan') ?? []).map((t) => t.textContent)).toEqual(['LIS', 'BON']);
});
it('gives every copy of a page its own ids, so a page drawn twice never clips to the other copy', () => {
const el = text({ fillPaint: { type: 'photo', photoId: 12 }, curve: { type: 'circle' } });
const { container } = render(
<>
<TextEffectsView el={el} frameStyle={frameStyle} big={false} />
<TextEffectsView el={el} frameStyle={frameStyle} big={false} />
</>
);
const ids = Array.from(container.querySelectorAll('[id]')).map((n) => n.id);
expect(ids).toHaveLength(4);
expect(new Set(ids).size).toBe(4);
});
});
describe('the page renderer', () => {
it('draws a text with effects through the effect view and a plain one as it always did', () => {
const { container: fx } = render(
<ElementView el={text({ outline: { color: '#111111', width: 0.3 } })} big={false} />
);
expect((fx.firstElementChild as HTMLElement).children).toHaveLength(2);
const { container: plain } = render(<ElementView el={text()} big={false} />);
const div = plain.firstElementChild as HTMLElement;
expect(div.textContent).toBe('Lisbon');
expect(div.children).toHaveLength(0);
});
});
@@ -0,0 +1,254 @@
import type { CSSProperties, ReactNode } from 'react';
import { useId } from 'react';
import type { BookTextElement } from '@trek/shared';
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn } from '@trek/shared';
import { fontStack } from './bookFonts';
import { photoSrc } from './bookRender';
import {
caseStyle,
curveAnchor,
isHollow,
letterFill,
lineAnchor,
lineCentres,
outlineStrokeMm,
photoCover,
ptToMm,
svgId,
withAlpha,
} from './textEffects';
/**
* A text with effects, drawn.
*
* ── Two ways of drawing, both vector in print ──────────────────────────────
*
* A text that still wraps (an outline, a shadow, a band, a case, a vertical
* place) stays HTML type, drawn as a stack of identical layers: the shadow, a
* copy offset behind; the band, a span background behind the words; the
* outline, a copy stroked twice its width under the fill, so the line grows
* outward and never thins the letter; then the fill. Every layer lays its
* words out the same way, so they land on each other exactly, and none of them
* is a blur, a filter or a `background-clip`, which print as pixels or wait
* for nothing.
*
* A text on a curve or filled with a photograph is SVG: the curve is
* `textPath` on a path computed in millimetres (book-text-curve.ts in shared),
* the photo an `<image>` clipped to the letters, which the print view waits
* for like any other picture. Such a text is set line by line as written.
*
* Every id is this instance's own: a page drawn twice on one sheet (page mode)
* must not have its second copy clip to the first copy's letters.
*/
const JUSTIFY = { top: 'flex-start', middle: 'center', bottom: 'flex-end' } as const;
/** The type itself, the same on every layer of an HTML text. */
function typeStyle(el: BookTextElement): CSSProperties {
return {
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: JUSTIFY[el.vAlign ?? 'top'],
fontSize: `${el.size}pt`,
fontFamily: fontStack(el.font),
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
textAlign: el.align,
whiteSpace: 'pre-wrap',
hyphens: 'auto',
...caseStyle(el),
};
}
/** The words of one layer, padded as the band pads them so every layer breaks its lines alike. */
function Words({ el, band }: { el: BookTextElement; band?: string }) {
const hl = el.highlight;
const style: CSSProperties | undefined = hl
? {
padding: `${hl.padY}mm ${hl.padX}mm`,
borderRadius: hl.radius ? `${hl.radius}mm` : undefined,
background: band,
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
}
: undefined;
return (
<div>
<span style={style}>{el.text}</span>
</div>
);
}
function LayeredText({ el, frameStyle }: { el: BookTextElement; frameStyle: CSSProperties }) {
const type = typeStyle(el);
const { outline, shadow, highlight } = el;
const hollow = isHollow(el);
const stroke = outline ? `${outlineStrokeMm(el)}mm` : undefined;
const reaches = !!(outline || shadow || highlight);
const layers: ReactNode[] = [];
if (shadow) {
const ink = withAlpha(shadow.color, shadow.alpha);
layers.push(
<div
key="shadow"
aria-hidden
style={{
...type,
transform: `translate(${shadow.dx}mm, ${shadow.dy}mm)`,
color: hollow ? 'transparent' : ink,
WebkitTextStroke: stroke ? `${stroke} ${ink}` : undefined,
}}
>
<Words el={el} band={highlight ? ink : undefined} />
</div>
);
}
if (highlight) {
layers.push(
<div key="band" aria-hidden style={{ ...type, color: 'transparent' }}>
<Words el={el} band={withAlpha(highlight.color, highlight.alpha)} />
</div>
);
}
if (outline) {
layers.push(
<div
key="outline"
aria-hidden
style={{
...type,
color: hollow ? 'transparent' : outline.color,
WebkitTextStroke: `${stroke} ${outline.color}`,
}}
>
<Words el={el} />
</div>
);
}
layers.push(
<div key="fill" style={{ ...type, color: letterFill(el) ?? el.color }}>
<Words el={el} />
</div>
);
return <div style={{ ...frameStyle, overflow: reaches ? 'visible' : 'hidden' }}>{layers}</div>;
}
interface GlyphPaint {
fill: string;
stroke?: string;
strokeWidth?: number;
}
function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean }) {
const reactId = useId();
const pathId = svgId('tp-', reactId);
const clipId = svgId('tc-', reactId);
const { w, h } = el.frame;
const fontMm = ptToMm(el.size);
const lines = bookDrawnLines(el);
const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null;
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
const hollow = isHollow(el);
const { outline, shadow } = el;
const textProps = {
fontFamily: fontStack(el.font),
fontSize: fontMm,
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
style: { letterSpacing: `${el.tracking}em`, fontVariantCaps: caseStyle(el).fontVariantCaps },
};
const glyphs = (paint: GlyphPaint | null, key: string) => {
const painted = paint ? { ...paint, strokeLinejoin: 'round' as const } : {};
if (curve) {
const at = curveAnchor(el.align);
return (
<text key={key} {...textProps} {...painted}>
<textPath href={`#${pathId}`} startOffset={at.offset} textAnchor={at.anchor}>
{lines[0]}
</textPath>
</text>
);
}
const at = lineAnchor(el.align, w);
const ys = lineCentres(lines.length, fontMm, el.leading, h, el.vAlign);
return (
<text key={key} {...textProps} {...painted} textAnchor={at.anchor} dominantBaseline="central">
{lines.map((line, i) => (
<tspan key={i} x={at.x} y={ys[i]}>
{line || ' '}
</tspan>
))}
</text>
);
};
const stroke = outlineStrokeMm(el);
const layers: ReactNode[] = [];
if (shadow) {
const ink = withAlpha(shadow.color, shadow.alpha);
layers.push(
<g key="shadow" transform={`translate(${shadow.dx} ${shadow.dy})`} aria-hidden>
{glyphs(
{ fill: hollow ? 'none' : ink, stroke: stroke ? ink : undefined, strokeWidth: stroke || undefined },
'g'
)}
</g>
);
}
if (outline) {
layers.push(
glyphs({ fill: hollow ? 'none' : outline.color, stroke: outline.color, strokeWidth: stroke }, 'outline')
);
}
if (photo) {
const cover = photoCover(w, h, photo);
layers.push(<image key="photo" href={photoSrc(photo.photoId, big)} {...cover} clipPath={`url(#${clipId})`} />);
} else {
layers.push(glyphs({ fill: letterFill(el) ?? el.color }, 'fill'));
}
return (
<div style={frameStyle}>
<svg
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
style={{ display: 'block', overflow: 'visible' }}
role="img"
aria-label={el.text}
>
<defs>
{curve && <path id={pathId} d={curve.d} fill="none" />}
{photo && (
<clipPath id={clipId} clipPathUnits="userSpaceOnUse">
{glyphs(null, 'clip')}
</clipPath>
)}
</defs>
{layers}
</svg>
</div>
);
}
/** A text element that has effects; SpreadView keeps drawing the plain ones itself. */
export function TextEffectsView({
el,
frameStyle,
big,
}: {
el: BookTextElement;
frameStyle: CSSProperties;
big: boolean;
}) {
if (bookTextIsDrawn(el)) return <DrawnText el={el} frameStyle={frameStyle} big={big} />;
return <LayeredText el={el} frameStyle={frameStyle} />;
}
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import type { BookSpread, BookTextElement } from '@trek/shared';
import { bookTextElementSchema } from '@trek/shared';
import {
caseStyle,
curveAnchor,
defaultBand,
defaultOutline,
defaultShadow,
firstFillPhoto,
hasTextEffects,
isHollow,
letterFill,
lineAnchor,
lineCentres,
outlineStrokeMm,
photoCover,
ptToMm,
svgId,
withAlpha,
} from './textEffects';
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({
id: 't1',
kind: 'text',
frame: { x: 0, y: 0, w: 100, h: 40 },
text: 'Lisbon',
size: 36,
...over,
});
describe('which texts take the effect path', () => {
it('leaves a plain text, and one set to the plain values, to the plain renderer', () => {
expect(hasTextEffects(text())).toBe(false);
expect(hasTextEffects(text({ transform: 'none', vAlign: 'top', outline: null, fit: 'shrink' }))).toBe(false);
});
it('takes every effect that draws', () => {
for (const over of [
{ transform: 'upper' },
{ vAlign: 'middle' },
{ outline: { color: '#111111', width: 0.4 } },
{ shadow: { dx: 1, dy: 1, color: '#111111' } },
{ highlight: { color: '#111111' } },
{ curve: { type: 'arc' } },
{ fillPaint: { type: 'solid', color: '#111111', alpha: 0.5 } },
]) {
expect(hasTextEffects(text(over)), JSON.stringify(over)).toBe(true);
}
});
});
describe('colours and strokes', () => {
it('keeps an opaque colour its hex and spells a translucent one out', () => {
expect(withAlpha('#ff8000')).toBe('#ff8000');
expect(withAlpha('#ff8000', 0.5)).toBe('rgba(255, 128, 0, 0.5)');
expect(withAlpha('#ff8000', -1)).toBe('rgba(255, 128, 0, 0)');
});
it('fills the letters with the colour, a solid fill, or nothing under a photo', () => {
expect(letterFill(text({ color: '#222222' }))).toBe('#222222');
expect(letterFill(text({ fillPaint: { type: 'solid', color: '#0f766e', alpha: 0.4 } }))).toBe(
'rgba(15, 118, 110, 0.4)'
);
expect(letterFill(text({ fillPaint: { type: 'photo', photoId: 3 } }))).toBeNull();
});
it('strokes a solid letter twice the outline, a hollow one at it, and nothing without one', () => {
const outline = { color: '#111111', width: 0.5 };
expect(outlineStrokeMm(text({ outline }))).toBe(1);
const hollow = text({ outline, fillPaint: { type: 'solid', color: '#111111', alpha: 0 } });
expect(isHollow(hollow)).toBe(true);
expect(outlineStrokeMm(hollow)).toBe(0.5);
expect(outlineStrokeMm(text())).toBe(0);
});
it('cases the letters in CSS', () => {
expect(caseStyle(text({ transform: 'upper' }))).toEqual({ textTransform: 'uppercase' });
expect(caseStyle(text({ transform: 'lower' }))).toEqual({ textTransform: 'lowercase' });
expect(caseStyle(text({ transform: 'smallcaps' }))).toEqual({ fontVariantCaps: 'small-caps' });
expect(caseStyle(text())).toEqual({});
});
});
describe('where a drawn text sits', () => {
it('anchors a line by the alignment, across the frame and along a curve', () => {
expect(lineAnchor('left', 100)).toEqual({ x: 0, anchor: 'start' });
expect(lineAnchor('center', 100)).toEqual({ x: 50, anchor: 'middle' });
expect(lineAnchor('right', 100)).toEqual({ x: 100, anchor: 'end' });
expect(lineAnchor('justify', 100)).toEqual({ x: 0, anchor: 'start' });
expect(curveAnchor('center')).toEqual({ offset: '50%', anchor: 'middle' });
expect(curveAnchor('right')).toEqual({ offset: '100%', anchor: 'end' });
});
it('stacks the lines at the leading, at the top, middle or foot of the frame', () => {
expect(lineCentres(2, 10, 1, 40, 'top')).toEqual([5, 15]);
expect(lineCentres(2, 10, 1, 40, 'middle')).toEqual([15, 25]);
expect(lineCentres(2, 10, 1, 40, 'bottom')).toEqual([25, 35]);
expect(lineCentres(1, 10, 1.2, 40, undefined)).toEqual([6]);
});
it('covers the letters with the photo, zoomed about the focal point', () => {
expect(photoCover(100, 40, { focalX: 0.5, focalY: 0.5, zoom: 1 })).toEqual({
x: 0,
y: 0,
width: 100,
height: 40,
preserveAspectRatio: 'xMidYMid slice',
});
const zoomed = photoCover(100, 40, { focalX: 0, focalY: 1, zoom: 2 });
expect(zoomed).toEqual({ x: -0, y: -40, width: 200, height: 80, preserveAspectRatio: 'xMinYMax slice' });
});
it('makes an id fit for url(#…) out of React’s', () => {
expect(svgId('tc-', ':r12:')).toBe('tc-r12');
expect(ptToMm(72)).toBeCloseTo(25.4, 6);
});
});
describe('an effect as it is switched on', () => {
it('arrives in a colour that stands out from the letters, sized from them', () => {
const dark = text({ color: '#1a1a1a', size: 72 });
expect(defaultOutline(dark)).toEqual({ color: '#ffffff', width: 1.27 });
expect(defaultShadow(dark)).toEqual({ dx: 2.03, dy: 2.03, color: '#ffffff', alpha: 1 });
expect(defaultBand(dark)).toEqual({ color: '#ffffff', alpha: 1, padX: 6.35, padY: 1.52, radius: 0 });
expect(defaultOutline(text({ color: '#ffffff', size: 4 })).width).toBe(0.1);
});
it('fills with a photo of the page first, then with the first of the journey', () => {
const spread = {
id: 'sp',
role: 'inner',
background: null,
parked: [],
entryId: null,
elements: [{ kind: 'photo', photoId: 9 }],
} as unknown as BookSpread;
expect(firstFillPhoto(spread, [{ photoId: 2 }])).toBe(9);
expect(firstFillPhoto(undefined, [{ photoId: 2 }])).toBe(2);
expect(firstFillPhoto(undefined, [])).toBeNull();
});
});
+162
View File
@@ -0,0 +1,162 @@
import type { BookSpread, BookTextElement } from '@trek/shared';
import { readableInk } from '@trek/shared';
/**
* The arithmetic behind TextEffectsView, kept apart from the component so it
* can be read and tested without a DOM: which texts take the effect path at
* all, the colours with their alpha, where each line of a drawn text sits and
* how a photo covers the letters it fills.
*/
/** Whether a text has anything SpreadView's plain text does not draw. A plain one keeps the plain path. */
export function hasTextEffects(el: BookTextElement): boolean {
return (
(!!el.transform && el.transform !== 'none') ||
(!!el.vAlign && el.vAlign !== 'top') ||
!!el.outline ||
!!el.shadow ||
!!el.highlight ||
!!el.curve ||
!!el.fillPaint
);
}
/** `#rrggbb` with an alpha, as CSS and SVG both take it. Fully opaque stays the hex itself. */
export function withAlpha(hex: string, alpha = 1): string {
if (alpha >= 1) return hex;
const n = Number.parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.max(0, alpha)})`;
}
/** The colour the letters are filled with, or null when a photograph fills them. */
export function letterFill(el: BookTextElement): string | null {
const fill = el.fillPaint;
if (!fill) return el.color;
return fill.type === 'solid' ? withAlpha(fill.color, fill.alpha) : null;
}
/** Hollow letters: a solid fill of no alpha, which leaves the outline alone on the page. */
export function isHollow(el: BookTextElement): boolean {
return el.fillPaint?.type === 'solid' && el.fillPaint.alpha === 0;
}
/**
* How wide an outline's stroke is drawn, in millimetres. A stroke is centred
* on the letter's edge and the fill covers its inner half, so a solid letter is
* stroked twice the outline's width; a hollow one shows the whole stroke, so it
* is stroked at the width itself. Zero without an outline.
*/
export function outlineStrokeMm(el: BookTextElement): number {
if (!el.outline) return 0;
return isHollow(el) ? el.outline.width : el.outline.width * 2;
}
/** Points to millimetres, the unit a drawn text's SVG is laid out in. */
export const ptToMm = (pt: number) => (pt * 25.4) / 72;
/** The CSS of a text's case. */
export function caseStyle(el: BookTextElement): {
textTransform?: 'uppercase' | 'lowercase';
fontVariantCaps?: 'small-caps';
} {
if (el.transform === 'upper') return { textTransform: 'uppercase' };
if (el.transform === 'lower') return { textTransform: 'lowercase' };
if (el.transform === 'smallcaps') return { fontVariantCaps: 'small-caps' };
return {};
}
const ANCHOR = { left: 'start', center: 'middle', right: 'end', justify: 'start' } as const;
/** How far across the frame, and along a curve, each anchor sits. */
const ACROSS = { start: 0, middle: 0.5, end: 1 } as const;
/** How far down the frame's spare room the block of lines sits. */
const DOWN = { top: 0, middle: 0.5, bottom: 1 } as const;
/** Where a drawn line starts and how it is anchored, from the text's alignment. */
export function lineAnchor(
align: BookTextElement['align'],
w: number
): { x: number; anchor: 'start' | 'middle' | 'end' } {
const anchor = ANCHOR[align];
return { x: w * ACROSS[anchor], anchor };
}
/** The same anchor along a curve: where on the path the words are pinned. */
export function curveAnchor(align: BookTextElement['align']): { offset: string; anchor: 'start' | 'middle' | 'end' } {
const anchor = ANCHOR[align];
return { offset: `${ACROSS[anchor] * 100}%`, anchor };
}
/**
* The centre line of each line of a drawn text, in millimetres from the
* frame's top: the lines stacked at the text's leading, the block set at the
* top, middle or foot of the frame. Drawn with `dominant-baseline: central`,
* which centres the letters on these lines.
*/
export function lineCentres(
count: number,
fontMm: number,
leading: number,
h: number,
vAlign: BookTextElement['vAlign']
): number[] {
const lineH = fontMm * leading;
const total = lineH * count;
const top = (h - total) * DOWN[vAlign ?? 'top'];
return Array.from({ length: count }, (_, i) => top + lineH * i + lineH / 2);
}
/**
* How a photograph lies under the letters it fills: a box `zoom` times the
* frame, moved so the focal point stays where it is wanted, and the picture
* cropped into that box from the side the focal point is on.
*/
export function photoCover(
w: number,
h: number,
fill: { focalX: number; focalY: number; zoom: number }
): { x: number; y: number; width: number; height: number; preserveAspectRatio: string } {
const width = w * fill.zoom;
const height = h * fill.zoom;
const side = (v: number) => {
if (v < 0.34) return 'Min';
return v > 0.66 ? 'Max' : 'Mid';
};
return {
x: (w - width) * fill.focalX,
y: (h - height) * fill.focalY,
width,
height,
preserveAspectRatio: `x${side(fill.focalX)}Y${side(fill.focalY)} slice`,
};
}
/** An id fit for `url(#…)`, from React's useId, which carries colons. */
export function svgId(prefix: string, reactId: string): string {
return `${prefix}${reactId.replace(/[^A-Za-z0-9_-]/g, '')}`;
}
const r2 = (v: number) => Math.round(v * 100) / 100;
/** An outline as it is first switched on: a contrasting line about a twentieth of the letter height. */
export function defaultOutline(el: BookTextElement): NonNullable<BookTextElement['outline']> {
return { color: readableInk(el.color), width: Math.max(0.1, r2(ptToMm(el.size) * 0.05)) };
}
/** A hard shadow as it is first switched on: offset down and to the right by a twelfth of the letter height. */
export function defaultShadow(el: BookTextElement): NonNullable<BookTextElement['shadow']> {
const off = Math.max(0.2, r2(ptToMm(el.size) * 0.08));
return { dx: off, dy: off, color: readableInk(el.color), alpha: 1 };
}
/** A band as it is first switched on: a contrasting ground with room of a quarter em either side. */
export function defaultBand(el: BookTextElement): NonNullable<BookTextElement['highlight']> {
const em = ptToMm(el.size);
return { color: readableInk(el.color), alpha: 1, padX: r2(em * 0.25), padY: r2(em * 0.06), radius: 0 };
}
/** The photo a photo fill starts from: the first picture on the page, else the first of the journey. */
export function firstFillPhoto(spread: BookSpread | undefined, gallery: readonly { photoId: number }[]): number | null {
const onPage = spread?.elements.find((e) => e.kind === 'photo' && e.photoId != null);
if (onPage?.kind === 'photo' && onPage.photoId != null) return onPage.photoId;
return gallery[0]?.photoId ?? null;
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import type { BookSpread } from '@trek/shared';
import { BOOK_LOCKUPS, bookElementSchema, bookPageSetupSchema, findBookLockup, formatBookCoords } from '@trek/shared';
import { SHELF_GROUPS, placeLockup, previewLockup, shelfLockups, shelfWords } from './textStylesModel';
const t = (k: string) => `[${k}]`;
const page = bookPageSetupSchema.parse({});
const source = {
entries: [
{ date: null, lat: null, lng: null },
{ date: '2025-08-12', lat: 41.1496, lng: -8.611 },
],
photos: [{ photoId: 3 }],
};
const lockup = (id: string) => {
const found = findBookLockup(id);
if (!found) throw new Error(`${id} is missing`);
return found;
};
describe('the words of the shelf', () => {
it("fills a lockup with the book's title, the year and point of its first stop, and samples for the rest", () => {
const words = shelfWords(' Porto ', source, t);
expect(words.title).toBe('Porto');
expect(words.year).toBe('2025');
expect(words.coords).toBe(formatBookCoords(41.1496, -8.611));
expect(words.subtitle).toBe('[journey.studio.lockupWord.subtitle]');
expect(words.number).toBe('01');
});
it('falls back to a sample title, this year and Lisbon for a book that has none of them', () => {
const words = shelfWords('', { entries: [], photos: [] }, t, new Date('2031-03-01T12:00:00'));
expect(words.title).toBe('[journey.studio.lockupWord.title]');
expect(words.year).toBe('2031');
expect(words.coords).toBe(formatBookCoords(38.7139, -9.1334));
});
});
describe('the shelf', () => {
it('shows every lockup of the catalogue once, in its groups', () => {
const shown = SHELF_GROUPS.flatMap((g) => shelfLockups(g.lockups).map((l) => l.id));
expect(shown).toHaveLength(BOOK_LOCKUPS.length);
expect(new Set(shown)).toEqual(new Set(BOOK_LOCKUPS.map((l) => l.id)));
expect(shelfLockups(['nope', 'stamp']).map((l) => l.id)).toEqual(['stamp']);
});
it('places a lockup on the page it is on, filling a photo word from that page', () => {
const spread = {
id: 'sp',
role: 'cover',
background: null,
parked: [],
entryId: null,
elements: [{ id: 'p', kind: 'photo', photoId: 8 }],
} as unknown as BookSpread;
let n = 0;
const els = placeLockup(lockup('photo-word'), {
page,
spread,
words: shelfWords('Porto', source, t),
gallery: source.photos,
newId: (p) => `${p}-${(n += 1)}`,
});
expect(els.map((e) => e.id)).toEqual(['t-1', 't-2']);
for (const el of els) expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(page.pageWidth);
const word = els[1];
expect(word?.kind === 'text' && word.fillPaint).toMatchObject({ type: 'photo', photoId: 8 });
});
it('draws a preview at the origin, 100 mm wide, every element one the document reads', () => {
const els = previewLockup(lockup('postmark'), shelfWords('Porto', source, t), null);
expect(els.length).toBeGreaterThan(3);
expect(new Set(els.map((e) => e.id)).size).toBe(els.length);
for (const el of els) {
expect(bookElementSchema.safeParse(el).success).toBe(true);
expect(el.frame.x).toBeGreaterThanOrEqual(0);
expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(100.01);
}
});
});
@@ -0,0 +1,103 @@
import type { BookElement, BookLockup, BookLockupWords, BookPageSetup, BookSpread } from '@trek/shared';
import { BOOK_LOCKUPS, bookLockupBox, buildBookLockup, formatBookCoords } from '@trek/shared';
import { firstFillPhoto } from './textEffects';
/**
* What the Text styles shelf places and draws: the words a lockup is filled
* with, the order the shelf shows the lockups in, and the elements one lockup
* becomes on a page.
*/
/** What the shelf knows of the journey: its stops, for a year and a point. */
export interface ShelfSource {
entries: readonly { date: string | null; lat: number | null; lng: number | null }[];
photos: readonly { photoId: number }[];
}
/** Lisbon, where the samples are set, for a journey that has recorded no point. */
const SAMPLE_POINT = { lat: 38.7139, lng: -9.1334 };
/**
* The words every lockup is filled with: the book's own title and the year of
* its first dated stop, its first recorded point, and translated samples for
* the rest, which are there to be overwritten.
*/
export function shelfWords(
title: string,
source: ShelfSource,
t: (k: string) => string,
now: Date = new Date()
): BookLockupWords {
const dated = source.entries.find((e) => e.date)?.date;
const year = dated ? dated.slice(0, 4) : String(now.getFullYear());
const point = source.entries.find((e) => e.lat != null && e.lng != null);
return {
title: title.trim() || t('journey.studio.lockupWord.title'),
subtitle: t('journey.studio.lockupWord.subtitle'),
kicker: t('journey.studio.lockupWord.kicker'),
overlay: t('journey.studio.lockupWord.overlay'),
year,
place: t('journey.studio.lockupWord.place'),
dates: t('journey.studio.lockupWord.dates'),
quote: t('journey.studio.lockupWord.quote'),
caption: t('journey.studio.lockupWord.caption'),
number: '01',
label: t('journey.studio.lockupWord.label'),
coords: formatBookCoords(point?.lat ?? SAMPLE_POINT.lat, point?.lng ?? SAMPLE_POINT.lng),
};
}
/** The shelf's groups, in the order a page is usually built: a title first, the small marks last. */
export const SHELF_GROUPS = [
{
id: 'titles',
lockups: [
'poster-stack',
'spaced-date',
'serif-sun',
'title-script-year',
'script-overlay',
'photo-word',
'fabric-band',
'arched-title',
'retro-shadow',
'outline-echo',
'minimal-caps',
],
},
{ id: 'badges', lockups: ['circle-badge', 'postmark', 'stamp', 'ticket'] },
{ id: 'chapters', lockups: ['chapter-outline', 'chapter-rules', 'day-opener'] },
{
id: 'quotes',
lockups: ['quote-classic', 'quote-band', 'caption-rule', 'caption-number', 'coords-pin', 'coords-stack'],
},
] as const;
/** A group's lockups, as the catalogue has them. */
export function shelfLockups(ids: readonly string[]): BookLockup[] {
return ids.flatMap((id) => BOOK_LOCKUPS.filter((l) => l.id === id));
}
/** Where a lockup is placed and with what. */
export interface LockupPlacement {
page: BookPageSetup;
spread: BookSpread | undefined;
words: BookLockupWords;
gallery: readonly { photoId: number }[];
newId: (prefix: string) => string;
}
/** The elements one lockup becomes, centred on the page it is placed on, a photo word filled from that page. */
export function placeLockup(lockup: BookLockup, at: LockupPlacement): BookElement[] {
const single = !!at.spread && at.spread.role !== 'inner';
const photoId = firstFillPhoto(at.spread, at.gallery);
return buildBookLockup(lockup, bookLockupBox(lockup, at.page, single), { words: at.words, photoId, newId: at.newId });
}
/** A lockup drawn in a box of its own proportions, 100 mm wide, for the shelf's preview. */
export function previewLockup(lockup: BookLockup, words: BookLockupWords, photoId: number | null): BookElement[] {
let n = 0;
const box = { x: 0, y: 0, w: 100, h: lockup.height };
return buildBookLockup(lockup, box, { words, photoId, newId: (p) => `${p}-preview-${(n += 1)}` });
}
+4 -3
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared'
import {
MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout,
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, innerSpreadBounds as innerBounds,
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, fitBookTexts, innerSpreadBounds as innerBounds,
withThemeLinks,
} from '@trek/shared'
import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard'
@@ -195,14 +195,15 @@ export const useStudioStore = create<StudioState>((set, get) => ({
}
}),
// A text set to fit its frame is solved after every change (book-text-fit.ts in shared).
commit: fn => set(s => {
if (!s.doc || s.readOnly) return {}
const next = fn(s.doc)
const next = fitBookTexts(fn(s.doc), s.doc)
if (next === s.doc) return {}
return { doc: next, past: [...s.past, s.doc].slice(-HISTORY_LIMIT), future: [] }
}),
apply: fn => set(s => (s.doc && !s.readOnly ? { doc: fn(s.doc) } : {})),
apply: fn => set(s => (s.doc && !s.readOnly ? { doc: fitBookTexts(fn(s.doc), s.doc) } : {})),
// A colour or face set by hand stops following the theme; a link set here takes the theme's value.
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
+85
View File
@@ -0,0 +1,85 @@
/* Text effects in the inspector and the Text styles shelf (StudioTextEffects.tsx,
StudioTextStyles.tsx). Kept beside studio.css, which is at its size ceiling. */
/* One effect: its switch, then its settings indented under it while it is on. */
.trek-dash .st-fx-group + .st-fx-group {
border-top: 1px solid var(--line);
margin-top: 4px;
padding-top: 4px;
}
.trek-dash .st-fx-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0 10px;
}
.trek-dash .st-fx-hint {
padding: 4px 0 6px;
}
/* The photos the letters can be filled with: the content panel's thumbnails, smaller. */
.trek-dash .st-fx-photos {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
.trek-dash .st-fx-photo {
aspect-ratio: 1;
min-width: 0;
border-radius: 7px;
overflow: hidden;
background: var(--surface-2);
border: 1px solid var(--line);
transition:
box-shadow 0.14s,
transform 0.14s ease-out;
}
.trek-dash .st-fx-photo:hover {
transform: translateY(-1px);
}
.trek-dash .st-fx-photo.is-on {
box-shadow: 0 0 0 2px var(--accent);
}
.trek-dash .st-fx-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* The Text styles shelf: each lockup drawn as it will print, on paper. */
.trek-dash .st-lockups {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.trek-dash .st-lockup {
position: relative;
min-width: 0;
aspect-ratio: 4 / 3;
border-radius: 11px;
overflow: hidden;
/* Paper, not app chrome: the lockup is shown on the page it will be printed on. */
background: white;
border: 1px solid var(--line);
transition:
transform 0.14s ease-out,
box-shadow 0.14s,
border-color 0.16s;
}
.trek-dash .st-lockup:hover {
transform: translateY(-1px);
border-color: var(--line-2);
box-shadow: var(--sh-md);
}
.trek-dash .st-lockup:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.trek-dash .st-lockup-art {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
transform-origin: 0 0;
pointer-events: none;
}
@@ -22,7 +22,7 @@ Always, whatever the energy:
- Bound texts keep the journal in sync: prefer bindings over copied words; never invent captions or facts.
The energy decides the rest:
- Calm: one dominant element per page; heading, then body, then captions; whitespace as a beat after a loud spread; margins at least the safe area plus 6 mm; everything on the margin grid and the gutter; one accent from the photos and two typefaces.
- Lively: still one hero per page, now layered: a photo over a colour block or a shape, the title over a photo, photos turned 2 to 6 degrees in a white or polaroid frame, oversize display type, colour blocking in the accent.
- Lively: still one hero per page, now layered: a photo over a colour block or a shape, the title over a photo, photos turned 2 to 6 degrees in a white or polaroid frame, oversize display type, colour blocking in the accent; set a cover title or a chapter mark with add_lockup, and give type on a photo an outline, a hard shadow or a band (style) rather than a scrim.
- Loud: lively pushed further: bigger type, stronger colour, more layers, a title that fills the cover; never two heroes on one page.
- A deliberate overlap is a design; an accidental one (two straight photos or texts of a size half over each other) is a fault.
- Vary the beats, never three alike in a row; a quiet page for a long story, grids for bursts.
+1 -1
View File
@@ -195,7 +195,7 @@ export class JourneyBookMcp {
@Tool({
name: 'edit_book_page',
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (a colour, or a paint: a colour with alpha, a linear or radial gradient, or one of twelve patterns; null is the paper), add (a new photo frame, text, shape, icon, sticker from list_book_assets, line, map, figures, country outlines, mark or list; a line runs through points on the spread in mm, with heads, dashes, a wave or an ornament; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, a shape's paint, fonts, strokes, a cut edge on a shape or photo such as a stamp's perforation, a line's points and the like, and for a photo its border, hard shadow, zoom, mirror, adjustments, duotone and cutout), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures), add_grid (a photo grid as empty frames in a box), group and ungroup (two or more elements picked, moved, aligned and arranged as one, such as a title lockup; align and distribute move a group as one box), name (an element or a group, as the layers panel shows it) and hide (left off the page and out of print, or shown again). A locked element refuses any operation that would change it but lock, ungroup, name and hide. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (a colour, or a paint: a colour with alpha, a linear or radial gradient, or one of twelve patterns; null is the paper), add (a new photo frame, text, shape, icon, sticker from list_book_assets, line, map, figures, country outlines, mark or list; a line runs through points on the spread in mm, with heads, dashes, a wave or an ornament; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, a shape's paint, fonts, strokes, a cut edge on a shape or photo such as a stamp's perforation, a line's points and the like, for a text also its case, vertical place, outline, hard shadow, marker band, curve on an arc or a circle, a fill of a gallery photo or hollow letters, and shrink to fit, and for a photo its border, hard shadow, zoom, mirror, adjustments, duotone and cutout), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures), add_grid (a photo grid as empty frames in a box), group and ungroup (two or more elements picked, moved, aligned and arranged as one, such as a title lockup; align and distribute move a group as one box), name (an element or a group, as the layers panel shows it), hide (left off the page and out of print, or shown again) and add_lockup (one of the Studio's text styles: a set title, badge, postmark, stamp, quote, caption or chapter mark of a few texts and shapes, its words given by role, every text fitted to its frame, placed as one group). A locked element refuses any operation that would change it but lock, ungroup, name and hide. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
inputSchema: { journeyId: idSchema, ...editBookPageArgs },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
+6 -2
View File
@@ -32,6 +32,7 @@ import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './boo
import { bookPaintInSchema } from './book-paint.schema';
import { BOOK_PHOTO_LOOK_PROPS } from './book-photo-look.schema';
import { bookStickerIdSchema } from './book-sticker';
import { TEXT_EFFECT_PROPS, bookAddLockupOpSchema } from './book-text-ops.schema';
import {
MAX_BOOK_COUNTRIES,
bookBadgeElementSchema,
@@ -51,7 +52,7 @@ import { z } from 'zod';
/**
* The element operations of a book page: create any of the ten kinds, style
* one, arrange, align, distribute, lock, duplicate, fill, copy from another
* page, refresh the travel figures, drop in a photo grid. They join the page
* page, refresh the travel figures, drop in a photo grid, set a lockup. They join the page
* operations of book-ops.schema.ts, so `edit_book_page` takes them and the one
* engine (`applyBookOps`) runs them; nothing else implements them.
*
@@ -116,7 +117,8 @@ export const TEXT_STYLE_FIELDS = {
tracking: text.tracking.unwrap().optional(),
color: text.color.unwrap().optional(),
};
const TEXT_PROPS = { ...COMMON, ...TEXT_STYLE_FIELDS, ...THEMED_TEXT_IN };
/** A text's look for `style` and `add`: its setting, its theme links and its effects (book-text-ops.schema.ts). */
const TEXT_PROPS = { ...COMMON, ...TEXT_STYLE_FIELDS, ...THEMED_TEXT_IN, ...TEXT_EFFECT_PROPS };
const SHAPE_PROPS = {
...COMMON,
fill: shape.fill.unwrap().optional(),
@@ -379,4 +381,6 @@ export const BOOK_ELEMENT_OP_SCHEMAS = [
z.strictObject({ op: z.literal('refresh_travel'), elementIds: ids30.optional() }),
/** A photo grid as empty frames in a box, the page's safe area by default. */
z.strictObject({ op: z.literal('add_grid'), grid: bookGridIdSchema, box: bookFrameInSchema.optional() }),
/** A lockup: a set title, badge, postmark, quote, caption or chapter mark (book-text-ops.schema.ts). */
bookAddLockupOpSchema,
] as const;
+3
View File
@@ -5,6 +5,7 @@ import { visibleSpread } from './book-groups';
import type { BookPhotoInfo } from './book-ops-core';
import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema';
import { photoZoomBox, photoZoomOf } from './book-photo-look';
import { bookTextHasOwnGround } from './book-text-findings';
import type { BookDocument, BookElement, BookFrame, BookPageSetup, BookPhotoElement, BookSpread } from './book.schema';
/**
@@ -142,6 +143,8 @@ const add = (ctx: Ctx, f: BookFinding) => ctx.out.push({ ...f, message: f.messag
function contrastAndBusy(ctx: Ctx): void {
ctx.spread.elements.forEach((el, i) => {
if (!READ.has(el.kind)) return;
// A band, an outline or a picture in the letters is the text's own ground (book-text-findings.ts).
if (el.kind === 'text' && bookTextHasOwnGround(el)) return;
const ink = inkOf(el);
const ground = groundOf(ctx.spread, i, el.frame, ctx.photos);
if (ground.kind === 'photo') {
+23 -4
View File
@@ -7,6 +7,8 @@ import { visibleSpread } from './book-groups';
import type { BookPhotoInfo } from './book-ops-core';
import type { BookFinding } from './book-ops.schema';
import { photoZoomOf } from './book-photo-look';
import { bookTextIsDrawn } from './book-text-effects.schema';
import { textEffectFindings } from './book-text-findings';
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema';
/**
@@ -110,6 +112,21 @@ function wrappedLines(para: string, perLine: number): number {
return lines;
}
/** How much wider capitals set than the family's average letter; a capitals-only face is set in them already. */
const CASE_WIDTH: Record<string, number> = { upper: 1.18, smallcaps: 1.08 };
/**
* The average advance of one letter of a text in millimetres, its tracking
* included: the family's letter width at the text's size, a little more when
* bold, and more again when it is set in capitals.
*/
export function bookLetterMm(el: BookTextElement, headroom = 1): number {
const sizeMm = (orDefault(el.size, 11) * 25.4) / 72;
const tracking = orDefault(el.tracking, 0);
const caps = bookFontFace(el.font)?.caps ? 1 : (CASE_WIDTH[el.transform ?? 'none'] ?? 1);
return sizeMm * (letterEm(el.font) * (el.weight >= 600 ? 1.06 : 1) * caps * headroom + tracking);
}
/**
* How many millimetres a text needs to be set at its frame's width, roughly:
* its words wrapped at the family's average letter width times `headroom`,
@@ -119,8 +136,7 @@ function wrappedLines(para: string, perLine: number): number {
*/
export function textHeightNeeded(el: BookTextElement, headroom = 1): number {
const sizeMm = (orDefault(el.size, 11) * 25.4) / 72;
const tracking = orDefault(el.tracking, 0);
const letter = sizeMm * (letterEm(el.font) * (el.weight >= 600 ? 1.06 : 1) * headroom + tracking);
const letter = bookLetterMm(el, headroom);
const perLine = Math.max(1, Math.floor(el.frame.w / Math.max(letter, 0.1)));
const lines = el.text.split('\n').reduce((sum, para) => sum + wrappedLines(para.trimEnd(), perLine), 0);
return lines * sizeMm * orDefault(el.leading, 1.45);
@@ -240,7 +256,8 @@ function overlapFinding(a: BookElement, b: BookElement, ba: Box, bb: Box, shared
* to the cap, so pairs layered on purpose never crowd out an accident.
*/
function overlaps(ctx: Ctx): void {
const content = ctx.spread.elements.filter((e) => e.kind === 'photo' || e.kind === 'text');
// A curved text's ink is its curve, not its frame: a badge's rim runs round what it holds.
const content = ctx.spread.elements.filter((e) => e.kind === 'photo' || (e.kind === 'text' && !e.curve));
const found: BookFinding[] = [];
for (let i = 0; i < content.length; i++) {
for (let j = i + 1; j < content.length; j++) {
@@ -461,7 +478,8 @@ function salientCut(ctx: Ctx, el: BookPhotoElement): void {
function overflow(ctx: Ctx): void {
for (const el of ctx.spread.elements) {
if (el.kind !== 'text' || !el.text.trim()) continue;
// A curved or photo-filled text is set as written, unwrapped: its own check measures it.
if (el.kind !== 'text' || !el.text.trim() || bookTextIsDrawn(el)) continue;
const need = textHeightNeeded(el);
if (need <= el.frame.h * 1.1 + 0.5) continue;
add(ctx, {
@@ -512,5 +530,6 @@ export function spreadFindings(
parked(ctx);
for (const f of stickerFindings(spread)) add(ctx, f);
for (const f of strokeFindings(spread)) add(ctx, f);
ctx.out.push(...textEffectFindings(spread, photos));
return sortBookFindings(ctx.out);
}
+589
View File
@@ -0,0 +1,589 @@
import type {
BookLockup,
BookLockupRole,
BookLockupShapePart,
BookLockupTextPart,
BookLockupTone,
} from './book-lockups';
/**
* The 24 lockups, in the units of a box 100 wide (book-lockups.ts).
*
* Type sizes are chosen against the share of the page a lockup takes, so the
* printed sizes land where the type scale wants them on a 210 mm page: a cover
* title between 70 and 180 pt, spaced capitals at 8 to 11 pt with a quarter to
* half an em of tracking, a title at least three times its subtitle. Every
* frame is a little taller than its line, and the parts never touch unless
* they are meant to layer, so a lockup placed as it comes has no severe finding.
*
* The faces are the book's own (book-fonts.data.ts): condensed and heavy
* poster faces for titles, Montserrat for spaced capitals, a thin serif and its
* italic for quiet titles and quotes, a script for the word laid over a title,
* DM Mono for coordinates.
*/
/** A line of spaced capitals: a date line, a kicker, a year. */
function spaced(
role: BookLockupRole,
y: number,
size: number,
tone: BookLockupTone = 'muted',
more: Partial<BookLockupTextPart> = {},
): BookLockupTextPart {
return {
kind: 'text',
role,
x: 0,
y,
w: 100,
h: size * 2,
font: 'montserrat',
size,
weight: 600,
tracking: 0.4,
transform: 'upper',
tone,
...more,
};
}
/** A filled bar: a rule when it is thin, a band when it is not. */
function bar(x: number, y: number, w: number, h: number, fill: BookLockupTone = 'ink'): BookLockupShapePart {
return { kind: 'shape', shape: 'rect', x, y, w, h, fill };
}
/** A circle drawn as a line only. */
function ring(inset: number, stroke: BookLockupTone, strokeWidth: number, size = 100): BookLockupShapePart {
const side = size - inset * 2;
return { kind: 'shape', shape: 'ellipse', x: inset, y: inset, w: side, h: side, fill: null, stroke, strokeWidth };
}
/** A condensed poster face, Bebas unless the part says another: capitals, tight lines. */
function poster(
role: BookLockupRole,
y: number,
size: number,
more: Partial<BookLockupTextPart> = {},
): BookLockupTextPart {
return {
kind: 'text',
role,
x: 0,
y,
w: 100,
h: size * 1.05,
font: 'bebas',
size,
leading: 0.9,
tracking: 0.02,
transform: 'upper',
tone: 'ink',
...more,
};
}
export const BOOK_LOCKUPS: readonly BookLockup[] = [
{
id: 'poster-stack',
descr: 'Heavy poster title with a hard accent shadow, a subtitle on an accent band, the year under it',
height: 62,
share: 0.8,
parts: [
poster('title', 0, 34, { font: 'anton', shadow: { dx: 0.9, dy: 0.9, tone: 'accent' } }),
poster('subtitle', 38, 7.5, {
h: 11,
tracking: 0.3,
tone: 'paper',
highlight: { tone: 'accent', padX: 2.4, padY: 0.5 },
}),
spaced('year', 54, 2.4, 'ink', { h: 6 }),
],
},
{
id: 'spaced-date',
descr: 'A spaced date line over a condensed title, a short accent rule and the place',
height: 50,
share: 0.75,
parts: [
spaced('dates', 0, 2.4, 'muted', { h: 6 }),
poster('title', 7, 30, { font: 'league', tracking: 0.04 }),
bar(42, 40.5, 16, 0.5, 'accent'),
spaced('place', 43.5, 2.4, 'ink', { h: 6, tracking: 0.3 }),
],
},
{
id: 'serif-sun',
descr: 'A large thin serif title, an italic subtitle, a sun between two hairlines and the year',
height: 64,
share: 0.8,
parts: [
{
kind: 'text',
role: 'title',
x: 0,
y: 0,
w: 100,
h: 28,
font: 'cormorant',
size: 26,
weight: 300,
leading: 1,
tracking: -0.01,
tone: 'ink',
},
{
kind: 'text',
role: 'subtitle',
x: 0,
y: 29,
w: 100,
h: 10,
font: 'cormorant',
size: 7.5,
italic: true,
tone: 'ink',
},
bar(18, 46.8, 26, 0.3),
{ kind: 'shape', shape: 'sun', x: 46, y: 43, w: 8, h: 8, fill: 'accent' },
bar(56, 46.8, 26, 0.3),
spaced('year', 55, 2.2, 'muted', { h: 6, tracking: 0.5 }),
],
},
{
id: 'title-script-year',
descr: 'A bold serif title, an accent script subtitle turned across its foot, the year in spaced capitals',
height: 50,
share: 0.75,
parts: [
{
kind: 'text',
role: 'title',
x: 0,
y: 0,
w: 100,
h: 25,
font: 'playfair',
size: 22,
weight: 700,
leading: 0.95,
tone: 'ink',
},
{
kind: 'text',
role: 'subtitle',
x: 12,
y: 24,
w: 76,
h: 13,
rotation: -4,
font: 'vibes',
size: 12,
italic: false,
tone: 'accent',
},
spaced('year', 42, 2.4, 'muted', { h: 6, tracking: 0.45 }),
],
},
{
id: 'circle-badge',
descr: 'A round accent badge: a kicker run round its rim inside a paper ring, the title and the year in the middle',
height: 100,
share: 0.45,
parts: [
{ kind: 'shape', shape: 'ellipse', x: 0, y: 0, w: 100, h: 100, fill: 'accent' },
ring(5, 'paper', 0.7),
spaced('kicker', 0, 6, 'paper', {
x: 7,
y: 7,
w: 86,
h: 86,
tracking: 0.25,
curve: { type: 'circle', bend: 0, start: 0 },
}),
poster('title', 36, 22, { x: 16, w: 68, h: 24, tone: 'paper', tracking: 0.04 }),
spaced('year', 62, 4.4, 'paper', { x: 25, w: 50, h: 8, tracking: 0.3 }),
],
},
{
id: 'postmark',
descr: 'A round postmark: the place round its rim between two rings, the date in the middle over a cancel wave',
height: 100,
share: 0.42,
parts: [
ring(0.5, 'ink', 1),
ring(14, 'ink', 0.5),
poster('place', 0, 12, {
x: 1,
y: 1,
w: 98,
h: 98,
tracking: 0.16,
curve: { type: 'circle', bend: 0, start: 0 },
}),
poster('dates', 38, 10, { x: 20, w: 60, h: 11, tracking: 0.08 }),
{ kind: 'shape', shape: 'wave', x: 22, y: 53, w: 56, h: 8, fill: 'ink' },
spaced('year', 63, 4.4, 'ink', { x: 25, w: 50, h: 8, tracking: 0.3 }),
],
},
{
id: 'script-overlay',
descr: 'A spaced poster title with a large accent script word turned over it, outlined in paper',
height: 58,
share: 0.8,
parts: [
poster('title', 22, 32, { tracking: 0.1 }),
{
kind: 'text',
role: 'overlay',
x: 8,
y: 0,
w: 84,
h: 30,
rotation: -6,
font: 'yellowtail',
size: 24,
weight: 400,
italic: false,
tone: 'accent',
outline: { tone: 'paper', width: 0.6 },
},
],
},
{
id: 'photo-word',
descr: 'A huge poster word filled with a photo, a spaced kicker over it',
height: 50,
share: 0.85,
parts: [
spaced('kicker', 0, 2.4, 'ink', { h: 6, tracking: 0.45 }),
poster('title', 7, 40, { h: 42, font: 'anton', fill: 'photo' }),
],
},
{
id: 'fabric-band',
descr: 'A poster title in paper on a full accent band, a script subtitle under it',
height: 44,
share: 0.8,
parts: [
bar(0, 0, 100, 26, 'accent'),
poster('title', 4, 18, { h: 18, tracking: 0.14, tone: 'paper' }),
{
kind: 'text',
role: 'subtitle',
x: 0,
y: 29,
w: 100,
h: 13,
font: 'satisfy',
size: 7,
italic: false,
tone: 'ink',
},
],
},
{
id: 'quote-classic',
descr:
'A large accent quotation mark, the quote in a serif italic, an accent rule and the source in spaced capitals',
height: 56,
share: 0.6,
parts: [
{
kind: 'text',
role: 'quote',
literal: '“',
x: 0,
y: 0,
w: 22,
h: 22,
font: 'playfair',
size: 26,
weight: 700,
leading: 0.8,
align: 'left',
tone: 'accent',
},
{
kind: 'text',
role: 'quote',
x: 0,
y: 23,
w: 100,
h: 24,
font: 'cormorant',
size: 6.5,
italic: true,
leading: 1.3,
align: 'left',
tone: 'ink',
},
bar(0, 50.8, 9, 0.4, 'accent'),
spaced('caption', 48, 2.8, 'muted', { x: 12, w: 88, h: 6, align: 'left', tracking: 0.3 }),
],
},
{
id: 'quote-band',
descr: 'A quote set in paper on ink bands behind each line, the source under it',
height: 42,
share: 0.7,
parts: [
{
kind: 'text',
role: 'quote',
x: 0,
y: 0,
w: 100,
h: 32,
font: 'sans',
size: 5,
weight: 600,
leading: 1.6,
align: 'left',
tone: 'paper',
highlight: { tone: 'ink', padX: 1.4, padY: 0.4 },
},
spaced('caption', 35, 2.6, 'muted', { h: 6, align: 'left', tracking: 0.3 }),
],
},
{
id: 'caption-rule',
descr: 'A caption in spaced capitals after a short rule',
height: 8,
share: 0.5,
parts: [
bar(0, 3.85, 8, 0.3),
spaced('caption', 0, 3.1, 'ink', { x: 11, w: 89, h: 8, align: 'left', tracking: 0.3 }),
],
},
{
id: 'caption-number',
descr: 'An accent figure beside a caption in a serif italic',
height: 16,
share: 0.5,
parts: [
poster('number', 0, 14, { w: 16, h: 16, align: 'left', tone: 'accent' }),
{
kind: 'text',
role: 'caption',
x: 18,
y: 1,
w: 82,
h: 14,
font: 'cormorant',
size: 4.8,
italic: true,
leading: 1.3,
align: 'left',
tone: 'ink',
},
],
},
{
id: 'ticket',
descr: 'A ticket outline holding a kicker, the place in poster capitals and the dates',
height: 40,
share: 0.55,
parts: [
{ kind: 'shape', shape: 'ticket', x: 0, y: 0, w: 100, h: 40, fill: null, stroke: 'ink', strokeWidth: 0.5 },
spaced('kicker', 7, 2.8, 'muted', { x: 12, w: 76, h: 5 }),
poster('place', 12, 15, { x: 12, w: 76, h: 16, tracking: 0.08 }),
spaced('dates', 28.5, 2.8, 'ink', { x: 12, w: 76, h: 5, font: 'mono', weight: 500, tracking: 0.12 }),
],
},
{
id: 'coords-pin',
descr: 'Coordinates in spaced figures after an accent map pin',
height: 10,
share: 0.5,
parts: [
{ kind: 'shape', shape: 'pin', x: 0, y: 1, w: 6, h: 8, fill: 'accent' },
spaced('coords', 0, 3.2, 'ink', {
x: 9,
w: 91,
h: 10,
align: 'left',
font: 'mono',
weight: 400,
tracking: 0.12,
transform: 'none',
}),
],
},
{
id: 'coords-stack',
descr: 'The place in poster capitals, an accent rule and the coordinates under it',
height: 30,
share: 0.5,
parts: [
poster('place', 0, 15, { h: 16, tracking: 0.1 }),
bar(42, 17.5, 16, 0.4, 'accent'),
spaced('coords', 21, 3, 'muted', { h: 7, font: 'mono', weight: 400, tracking: 0.08, transform: 'none' }),
],
},
{
id: 'chapter-outline',
descr: 'A huge hollow outlined figure, a spaced accent label under it and the title in a serif italic',
height: 62,
share: 0.45,
parts: [
poster('number', 0, 42, { h: 42, fill: { tone: 'ink', alpha: 0 }, outline: { tone: 'accent', width: 0.5 } }),
spaced('label', 43, 3.6, 'accent', { h: 7, tracking: 0.5 }),
{
kind: 'text',
role: 'title',
x: 0,
y: 51,
w: 100,
h: 11,
font: 'cormorant',
size: 8,
italic: true,
tone: 'ink',
},
],
},
{
id: 'chapter-rules',
descr: 'A serif figure between two hairlines, the title in spaced capitals under it',
height: 34,
share: 0.5,
parts: [
bar(0, 12.8, 32, 0.35),
{ kind: 'text', role: 'number', x: 34, y: 2, w: 32, h: 22, font: 'bodoni', size: 17, leading: 1, tone: 'ink' },
bar(68, 12.8, 32, 0.35),
spaced('title', 26, 3.4, 'ink', { h: 7 }),
],
},
{
id: 'day-opener',
descr: 'A spaced label, a huge accent serif figure and the place in poster capitals, set flush left',
height: 58,
share: 0.5,
parts: [
spaced('label', 0, 3.4, 'muted', { h: 7, align: 'left', tracking: 0.5 }),
{
kind: 'text',
role: 'number',
x: 0,
y: 7,
w: 100,
h: 37,
font: 'fraunces',
size: 40,
weight: 800,
leading: 0.85,
align: 'left',
tone: 'accent',
},
poster('place', 46, 10, { h: 11, align: 'left', tracking: 0.14 }),
],
},
{
id: 'stamp',
descr: 'A rubber stamp set at an angle: a double accent frame round the place and the dates',
height: 44,
share: 0.45,
rotation: -7,
parts: [
{
kind: 'shape',
shape: 'rect',
x: 0,
y: 0,
w: 100,
h: 44,
fill: null,
stroke: 'accent',
strokeWidth: 1,
radius: 2,
},
{
kind: 'shape',
shape: 'rect',
x: 3.5,
y: 3.5,
w: 93,
h: 37,
fill: null,
stroke: 'accent',
strokeWidth: 0.35,
radius: 1,
},
poster('place', 8, 17, { x: 7, w: 86, h: 18, tracking: 0.12, tone: 'accent' }),
spaced('dates', 28, 3.4, 'accent', { x: 7, w: 86, h: 7, tracking: 0.35 }),
],
},
{
id: 'arched-title',
descr: 'A poster title arched over a script subtitle, a sparkle at each side',
height: 58,
share: 0.75,
parts: [
poster('title', 0, 20, { h: 42, tracking: 0.08, curve: { type: 'arc', bend: 42, start: 0 } }),
{ kind: 'shape', shape: 'sparkle', x: 6, y: 43, w: 7, h: 7, fill: 'accent' },
{
kind: 'text',
role: 'subtitle',
x: 16,
y: 41,
w: 68,
h: 12,
font: 'dancing',
size: 7,
italic: false,
weight: 500,
tone: 'ink',
},
{ kind: 'shape', shape: 'sparkle', x: 87, y: 43, w: 7, h: 7, fill: 'accent' },
],
},
{
id: 'retro-shadow',
descr: 'A heavy retro title in the accent, outlined in ink with a hard ink shadow, a subtitle and the year',
height: 48,
share: 0.75,
parts: [
{
kind: 'text',
role: 'title',
x: 0,
y: 0,
w: 100,
h: 28,
font: 'shrikhand',
size: 21,
weight: 400,
leading: 0.95,
tracking: -0.01,
tone: 'accent',
outline: { tone: 'ink', width: 0.5 },
shadow: { dx: 1.3, dy: 1.3, tone: 'ink' },
},
spaced('subtitle', 32, 2.8, 'ink', { h: 7, tracking: 0.35 }),
spaced('year', 40, 2.4, 'muted', { h: 7, tracking: 0.35 }),
],
},
{
id: 'outline-echo',
descr: 'The title three times in poster capitals: two hollow outlines over the solid word, the year under it',
height: 66,
share: 0.7,
parts: [
poster('title', 0, 17, { h: 18, fill: { tone: 'ink', alpha: 0 }, outline: { tone: 'ink', width: 0.3 } }),
poster('title', 19, 17, { h: 18, fill: { tone: 'ink', alpha: 0 }, outline: { tone: 'ink', width: 0.3 } }),
poster('title', 38, 17, { h: 18 }),
spaced('year', 59, 2.6, 'muted', { h: 6, tracking: 0.45 }),
],
},
{
id: 'minimal-caps',
descr: 'A title in widely spaced light capitals, a hairline and the year: quiet and elegant',
height: 30,
share: 0.7,
parts: [
spaced('title', 0, 8, 'ink', { h: 13, weight: 300, tracking: 0.55 }),
bar(44, 16.5, 12, 0.3),
spaced('year', 21, 2.6, 'muted', { h: 7, weight: 400, tracking: 0.5 }),
],
},
];
+181
View File
@@ -0,0 +1,181 @@
import { contrastRatio } from './book-colour';
import {
BOOK_LOCKUP_PALETTE,
addBookLockup,
buildBookLockup,
bookLockupBox,
findBookLockup,
type BookLockupWords,
} from './book-lockups';
import { BOOK_LOCKUPS } from './book-lockups.data';
import { formatBookCoords } from './book-text';
import { bookTextFits } from './book-text-fit';
import { bookElementSchema, bookPageSetupSchema, type BookSpread, type BookTextElement } from './book.schema';
import { pageFindings } from './describe-spread';
import { describe, expect, it } from 'vitest';
/**
* The lockups: every one builds into elements the document reads, its words
* fitted into their frames, and placed as it comes on a blank page it has no
* finding worth fixing, with a short title and with a long one.
*/
const page = bookPageSetupSchema.parse({});
const WORDS: BookLockupWords = {
title: 'Lisbon',
subtitle: 'A summer by the sea',
kicker: 'Travel journal',
overlay: 'travel',
year: '2026',
place: 'Portugal',
dates: '12 to 26 August',
quote: 'The best views come after the hardest climbs.',
caption: 'Alfama at first light',
number: '01',
label: 'Day',
coords: formatBookCoords(38.7139, -9.1334),
};
const LONG: BookLockupWords = {
...WORDS,
title: 'Two weeks along the Portuguese coast',
place: 'Sintra and the coast',
};
let seq = 0;
const newId = (p: string) => `${p}-${++seq}`;
const spreadOf = (elements: BookSpread['elements']): BookSpread => ({
id: 'sp',
role: 'inner',
background: null,
elements,
parked: [],
entryId: null,
});
describe('the lockup catalogue', () => {
it('BKLCK-001: holds 24 lockups with unique ids, each described and drawn inside its own box', () => {
expect(BOOK_LOCKUPS).toHaveLength(24);
expect(new Set(BOOK_LOCKUPS.map((l) => l.id)).size).toBe(24);
for (const l of BOOK_LOCKUPS) {
expect(l.descr.length, l.id).toBeGreaterThan(20);
expect(l.share, l.id).toBeGreaterThan(0);
expect(l.share, l.id).toBeLessThanOrEqual(1);
for (const p of l.parts) {
expect(p.x, `${l.id} x`).toBeGreaterThanOrEqual(0);
expect(p.x + p.w, `${l.id} right`).toBeLessThanOrEqual(100.01);
expect(p.y, `${l.id} y`).toBeGreaterThanOrEqual(0);
expect(p.y + p.h, `${l.id} foot`).toBeLessThanOrEqual(l.height + 0.01);
}
}
expect(findBookLockup('postmark')?.id).toBe('postmark');
expect(findBookLockup('nope')).toBeUndefined();
});
it('BKLCK-002: sets its small lines in a grey that still reads on white paper', () => {
expect(contrastRatio(BOOK_LOCKUP_PALETTE.muted, BOOK_LOCKUP_PALETTE.paper)).toBeGreaterThanOrEqual(4.5);
});
for (const [name, words] of [
['short', WORDS],
['long', LONG],
] as const) {
it(`BKLCK-003 (${name} words): builds every lockup into readable, fitted elements with no severe or warning finding`, () => {
for (const l of BOOK_LOCKUPS) {
const els = buildBookLockup(l, bookLockupBox(l, page, false), { words, newId, photoId: 5 });
expect(els.length, l.id).toBeGreaterThan(0);
for (const el of els) expect(bookElementSchema.safeParse(el).success, `${l.id} ${el.id}`).toBe(true);
for (const el of els.filter((e): e is BookTextElement => e.kind === 'text')) {
expect(bookTextFits(el), `${l.id} "${el.text}"`).toBe(true);
expect(el.fit).toBe('shrink');
}
const found = pageFindings(spreadOf(els), page, [{ photoId: 5, width: 4000, height: 3000 }]);
expect(
found.filter((f) => f.severity !== 'info').map((f) => `${f.code}: ${f.message}`),
l.id,
).toEqual([]);
}
});
}
});
describe('building a lockup', () => {
const poster = findBookLockup('poster-stack');
if (!poster) throw new Error('poster-stack is missing');
it('BKLCK-004: scales its units onto the box it is placed in, centred in it, keeping its proportions', () => {
const els = buildBookLockup(poster, { x: 0, y: 0, w: 200, h: 400 }, { words: WORDS, newId });
const title = els[0];
expect(title?.frame.w).toBeCloseTo(200, 1);
// 62 units tall at 2 mm a unit, centred in a 400 mm box.
expect(title?.frame.y).toBeCloseTo((400 - 124) / 2, 1);
});
it('BKLCK-005: leaves out the parts that were given no words, and builds nothing from no words at all', () => {
const els = buildBookLockup(poster, { x: 0, y: 0, w: 100, h: 62 }, { words: { title: 'Porto' }, newId });
expect(els.map((e) => (e.kind === 'text' ? e.text : e.kind))).toEqual(['Porto']);
expect(buildBookLockup(poster, { x: 0, y: 0, w: 100, h: 62 }, { words: {}, newId })).toEqual([]);
const quote = findBookLockup('quote-classic');
if (!quote) throw new Error('quote-classic is missing');
// The quotation mark is fixed words: alone, it is not a lockup anybody placed.
expect(buildBookLockup(quote, { x: 0, y: 0, w: 100, h: 56 }, { words: {}, newId })).toEqual([]);
});
it('BKLCK-006: takes its colours from the palette it is given', () => {
const els = buildBookLockup(
poster,
{ x: 0, y: 0, w: 100, h: 62 },
{
words: WORDS,
newId,
palette: { ink: '#202020', accent: '#0f766e' },
},
);
const [title, subtitle] = els as BookTextElement[];
expect(title?.color).toBe('#202020');
expect(title?.shadow?.color).toBe('#0f766e');
expect(subtitle?.highlight?.color).toBe('#0f766e');
});
it('BKLCK-007: fills a photo word with the photo it is given, and with the accent without one', () => {
const word = findBookLockup('photo-word');
if (!word) throw new Error('photo-word is missing');
const box = { x: 0, y: 0, w: 100, h: 50 };
const withPhoto = buildBookLockup(word, box, { words: WORDS, newId, photoId: 9 }).find(
(e) => e.kind === 'text' && e.fillPaint,
);
expect(withPhoto?.kind === 'text' && withPhoto.fillPaint).toMatchObject({ type: 'photo', photoId: 9 });
const title = buildBookLockup(word, box, { words: WORDS, newId }).at(-1) as BookTextElement;
expect(title.fillPaint).toBeUndefined();
expect(title.color).toBe(BOOK_LOCKUP_PALETTE.accent);
});
it('BKLCK-008: turns a stamp as a whole, about its own centre', () => {
const stamp = findBookLockup('stamp');
if (!stamp) throw new Error('stamp is missing');
const els = buildBookLockup(stamp, { x: 0, y: 0, w: 100, h: 44 }, { words: WORDS, newId });
for (const el of els) expect(el.rotation).toBe(-7);
const outer = els[0];
expect((outer?.frame.x ?? 0) + (outer?.frame.w ?? 0) / 2).toBeCloseTo(50, 1);
expect((outer?.frame.y ?? 0) + (outer?.frame.h ?? 0) / 2).toBeCloseTo(22, 1);
});
it('BKLCK-009: is placed on the right-hand page of a double spread and on the page of a single one', () => {
const inner = bookLockupBox(poster, page, false);
expect(inner.x).toBeGreaterThan(page.pageWidth);
const single = bookLockupBox(poster, page, true);
expect(single.x + single.w).toBeLessThan(page.pageWidth);
expect(single.w).toBeCloseTo(page.pageWidth * poster.share, 1);
});
it('BKLCK-010: lays a lockup in front of the page as one group, and a single element without one', () => {
const els = buildBookLockup(poster, { x: 0, y: 0, w: 100, h: 62 }, { words: WORDS, newId });
const sp = addBookLockup(spreadOf([]), els, 'g-1');
expect(sp.elements.map((e) => e.group)).toEqual(['g-1', 'g-1', 'g-1']);
const one = buildBookLockup(poster, { x: 0, y: 0, w: 100, h: 62 }, { words: { title: 'Porto' }, newId });
const alone = addBookLockup(spreadOf([]), one, 'g-2');
expect(alone.elements[0]?.group).toBeUndefined();
});
});
+329
View File
@@ -0,0 +1,329 @@
import { groupElements } from './book-groups';
import { BOOK_LOCKUPS } from './book-lockups.data';
import type { BookTextCurve, BookTextTransform } from './book-text-effects.schema';
import { solveBookTextFit } from './book-text-fit';
import type {
BookElement,
BookFontFamily,
BookFrame,
BookPageSetup,
BookSpread,
BookShapeElement,
BookShapeId,
BookTextElement,
} from './book.schema';
/**
* Lockups: titles, date lines, badges, postmarks, quotes and chapter marks set
* as a few texts and shapes that belong together, the way the covers of the
* reference books are composed.
*
* ── How a lockup is described ───────────────────────────────────────────────
*
* In the units of a box 100 wide and `height` tall, every size and position in
* those units and every colour a tone (ink, accent, paper, muted) rather than a
* value. `buildBookLockup` scales the units onto the box it is placed in and
* the tones onto the palette it is given, so one description sets a cover
* title on a large square book and a chapter mark on a small landscape one.
*
* ── The words ───────────────────────────────────────────────────────────────
*
* A text part names the role its words play (title, year, place …), and the
* words arrive with the placement: the Studio passes the book's own title and
* translated samples, the assistant the words it was asked for. A part whose
* role has no words is left out, so a lockup asked for only a title is a
* title. Every text part is set to fit its frame, so a long title shrinks into
* its place instead of running out of it.
*
* ── What it builds ──────────────────────────────────────────────────────────
*
* Ordinary elements, back to front, which move, restyle, undo and print like
* any other, placed as one group (`addBookLockup`) so the set moves as one and
* a double click still reaches a single part.
*/
export const BOOK_LOCKUP_ROLES = [
'title',
'subtitle',
'kicker',
'overlay',
'year',
'place',
'dates',
'quote',
'caption',
'number',
'label',
'coords',
] as const;
export type BookLockupRole = (typeof BOOK_LOCKUP_ROLES)[number];
export type BookLockupWords = Partial<Record<BookLockupRole, string>>;
/** The colours a lockup is set in, by what they do. */
export type BookLockupTone = 'ink' | 'accent' | 'paper' | 'muted';
export type BookLockupPalette = Record<BookLockupTone, string>;
/**
* Ink black, a warm terracotta accent, white paper, and a warm grey a shade
* darker than the Studio's captions, so the small spaced lines it sets still
* read at the 4.5:1 small type needs.
*/
export const BOOK_LOCKUP_PALETTE: BookLockupPalette = {
ink: '#1a1a1a',
accent: '#b4532a',
paper: '#ffffff',
muted: '#7a7468',
};
interface PartBox {
x: number;
y: number;
w: number;
h: number;
/** Degrees, turned about the part's own centre. */
rotation?: number;
}
export interface BookLockupTextPart extends PartBox {
kind: 'text';
/** Where the words come from. */
role: BookLockupRole;
/** Fixed words, for a mark such as a quotation mark: set whatever the words given. */
literal?: string;
font: BookFontFamily;
size: number;
weight?: BookTextElement['weight'];
italic?: boolean;
align?: BookTextElement['align'];
leading?: number;
tracking?: number;
tone: BookLockupTone;
transform?: BookTextTransform;
outline?: { tone: BookLockupTone; width: number };
shadow?: { dx: number; dy: number; tone: BookLockupTone; alpha?: number };
highlight?: { tone: BookLockupTone; padX: number; padY: number; radius?: number; alpha?: number };
curve?: BookTextCurve;
/** `photo` fills the letters with the photo the placement names, else with the accent; a tone with alpha 0 leaves them hollow. */
fill?: 'photo' | { tone: BookLockupTone; alpha: number };
}
export interface BookLockupShapePart extends PartBox {
kind: 'shape';
shape: BookShapeId;
fill: BookLockupTone | null;
stroke?: BookLockupTone;
strokeWidth?: number;
strokeStyle?: BookShapeElement['strokeStyle'];
radius?: number;
}
export type BookLockupPart = BookLockupTextPart | BookLockupShapePart;
export interface BookLockup {
id: string;
/** The box is 100 units wide and this tall. */
height: number;
/** How much of a page's width it takes when placed without a box. */
share: number;
/** The whole lockup turned about its centre, in degrees: a stamp set at an angle. */
rotation?: number;
/** What it is, for the assistant's schema and the shelf's tooltips. */
descr: string;
parts: readonly BookLockupPart[];
}
export function findBookLockup(id: string): BookLockup | undefined {
return BOOK_LOCKUPS.find((l) => l.id === id);
}
const r2 = (v: number) => Math.round(v * 100) / 100;
/**
* Where a lockup goes when no box is named: its share of the page width,
* centred on the page, or on the right-hand page of a double spread so it never
* lands in the fold.
*/
export function bookLockupBox(lockup: BookLockup, page: BookPageSetup, single: boolean): BookFrame {
const w = Math.min(page.pageWidth * lockup.share, ((page.pageHeight * 0.8) / lockup.height) * 100);
const h = (w / 100) * lockup.height;
const origin = single ? 0 : page.pageWidth;
return { x: r2(origin + (page.pageWidth - w) / 2), y: r2((page.pageHeight - h) / 2), w: r2(w), h: r2(h) };
}
/** The lockup's own proportions, as large as the box allows and centred in it. */
function fitted(lockup: BookLockup, box: BookFrame): { x: number; y: number; scale: number } {
const scale = Math.min(box.w / 100, box.h / lockup.height);
return {
x: box.x + (box.w - 100 * scale) / 2,
y: box.y + (box.h - lockup.height * scale) / 2,
scale,
};
}
interface Placement {
origin: { x: number; y: number; scale: number };
centre: { x: number; y: number };
turn: number;
}
/** A part's frame on the page and its rotation, the lockup's own turn applied about the lockup's centre. */
function placed(part: PartBox, at: Placement): { frame: BookFrame; rotation: number } {
const { x, y, scale } = at.origin;
const w = part.w * scale;
const h = part.h * scale;
let cx = x + (part.x + part.w / 2) * scale;
let cy = y + (part.y + part.h / 2) * scale;
if (at.turn) {
const rad = (at.turn * Math.PI) / 180;
const dx = cx - at.centre.x;
const dy = cy - at.centre.y;
cx = at.centre.x + dx * Math.cos(rad) - dy * Math.sin(rad);
cy = at.centre.y + dx * Math.sin(rad) + dy * Math.cos(rad);
}
const frame = { x: r2(cx - w / 2), y: r2(cy - h / 2), w: r2(Math.max(0.5, w)), h: r2(Math.max(0.3, h)) };
return { frame, rotation: r2((part.rotation ?? 0) + at.turn) };
}
/** Units to points at a scale of `scale` millimetres a unit, inside the document's range. */
const unitsToPt = (units: number, scale: number) =>
Math.min(400, Math.max(4, Math.round(((units * scale * 72) / 25.4) * 10) / 10));
export interface BuildLockupOptions {
words: BookLockupWords;
palette?: Partial<BookLockupPalette>;
/** The photo a photo-filled part is filled with; without one it is filled with the accent. */
photoId?: number | null;
newId: (prefix: string) => string;
}
const BASE = { opacity: 1, locked: false };
function textFill(part: BookLockupTextPart, tones: BookLockupPalette, photoId: number | null | undefined) {
if (part.fill === 'photo') {
return photoId ? { type: 'photo' as const, photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } : null;
}
return part.fill ? { type: 'solid' as const, color: tones[part.fill.tone], alpha: part.fill.alpha } : null;
}
function textPart(
part: BookLockupTextPart,
words: string,
at: Placement,
opts: BuildLockupOptions,
tones: BookLockupPalette,
) {
const { frame, rotation } = placed(part, at);
const s = at.origin.scale;
const size = unitsToPt(part.size, s);
const fillPaint = textFill(part, tones, opts.photoId);
const el: BookTextElement = {
...BASE,
id: opts.newId('t'),
kind: 'text',
frame,
rotation,
text: words,
font: part.font,
size,
weight: part.weight ?? 400,
italic: part.italic ?? false,
align: part.align ?? 'center',
leading: part.leading ?? 1.1,
tracking: part.tracking ?? 0,
// A photo fill that found no photo falls back to the accent, so the word still carries colour.
color: part.fill === 'photo' && !fillPaint ? tones.accent : tones[part.tone],
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: size,
...(part.transform ? { transform: part.transform } : {}),
...(fillPaint ? { fillPaint } : {}),
...(part.outline ? { outline: { color: tones[part.outline.tone], width: r2(part.outline.width * s) } } : {}),
...(part.shadow
? {
shadow: {
dx: r2(part.shadow.dx * s),
dy: r2(part.shadow.dy * s),
color: tones[part.shadow.tone],
alpha: part.shadow.alpha ?? 1,
},
}
: {}),
...(part.highlight
? {
highlight: {
color: tones[part.highlight.tone],
alpha: part.highlight.alpha ?? 1,
padX: r2(part.highlight.padX * s),
padY: r2(part.highlight.padY * s),
radius: r2((part.highlight.radius ?? 0) * s),
},
}
: {}),
...(part.curve ? { curve: part.curve } : {}),
};
return { ...el, size: solveBookTextFit(el, size) };
}
function shapePart(part: BookLockupShapePart, at: Placement, opts: BuildLockupOptions, tones: BookLockupPalette) {
const { frame, rotation } = placed(part, at);
const s = at.origin.scale;
const el: BookShapeElement = {
...BASE,
id: opts.newId('s'),
kind: 'shape',
frame,
rotation,
shape: part.shape,
fill: part.fill ? tones[part.fill] : null,
gradient: 'none',
stroke: part.stroke ? tones[part.stroke] : null,
strokeWidth: part.stroke ? r2(Math.max(0.1, (part.strokeWidth ?? 0.4) * s)) : 0,
strokeStyle: part.strokeStyle ?? 'solid',
radius: r2((part.radius ?? 0) * s),
};
return el;
}
/**
* The lockup's elements in `box`, back to front, its parts without words left
* out. Empty when no text part has words: a lockup of ornaments alone is not
* what anybody placed.
*/
export function buildBookLockup(lockup: BookLockup, box: BookFrame, opts: BuildLockupOptions): BookElement[] {
const tones = { ...BOOK_LOCKUP_PALETTE, ...opts.palette };
const origin = fitted(lockup, box);
const at: Placement = {
origin,
centre: { x: origin.x + 50 * origin.scale, y: origin.y + (lockup.height / 2) * origin.scale },
turn: lockup.rotation ?? 0,
};
const out: BookElement[] = [];
let words = 0;
for (const part of lockup.parts) {
if (part.kind === 'shape') {
out.push(shapePart(part, at, opts, tones));
continue;
}
const text = (part.literal ?? opts.words[part.role] ?? '').trim();
if (!text) continue;
if (!part.literal) words += 1;
out.push(textPart(part, text, at, opts, tones));
}
return words ? out : [];
}
/**
* A lockup's elements laid on a spread in front of what is there, made one
* group under `groupId` when there is more than one of them.
*/
export function addBookLockup(spread: BookSpread, made: readonly BookElement[], groupId: string): BookSpread {
const placed = { ...spread, elements: [...spread.elements, ...made] };
if (made.length < 2) return placed;
return groupElements(
placed,
made.map((e) => e.id),
groupId,
);
}
+8 -2
View File
@@ -17,6 +17,7 @@ import {
type BookOpsContext,
} from './book-ops-core';
import { bookLineStyled, bookNewLine } from './book-ops-line';
import { checkTextFillPhoto, opAddLockup } from './book-ops-lockup';
import { newStickerElement } from './book-ops-sticker';
import { withShapePaint } from './book-paint';
import type { BookPaint } from './book-paint.schema';
@@ -54,7 +55,7 @@ import type { z } from 'zod';
/**
* The element operations of the book engine: what `add`, `style`, `arrange`,
* `align`, `distribute`, `lock`, `duplicate`, `fill`, `copy_from`,
* `refresh_travel` and `add_grid` do to a page, and the `auto` layout. Called
* `refresh_travel`, `add_grid` and `add_lockup` do to a page, and the `auto` layout. Called
* from `applyBookOps` (book-ops.ts), which parses, runs a call all or nothing
* and checks the page it hands back against the document's schema.
*
@@ -99,6 +100,8 @@ export function applyElementOp(spread: BookSpread, op: ElementOp, env: ElementOp
return opRefreshTravel(spread, op, env);
case 'add_grid':
return opAddGrid(spread, op, env);
case 'add_lockup':
return opAddLockup(spread, op, env);
}
}
@@ -195,6 +198,7 @@ function opAdd(spread: BookSpread, op: Op<'add'>, env: ElementOpEnv): BookSpread
roomFor(spread, 1, env.opIndex);
const blank = created(spread, op.element, op.frame ? roundFrame(op.frame) : null, env);
const made = withProps(blank, lookOf(op.element), env.doc.theme);
checkTextFillPhoto(made, env);
const el = op.rotation !== undefined ? { ...made, rotation: op.rotation } : made;
const parked =
el.kind === 'photo' && el.photoId != null
@@ -435,7 +439,9 @@ function opStyle(spread: BookSpread, op: Op<'style'>, env: ElementOpEnv): BookSp
if (op.set.kind !== el.kind) {
throw failOp('wrong_kind', `${el.id} is a ${el.kind}; set.kind says ${op.set.kind}`, env.opIndex, el.id);
}
return replaceAll(spread, [withProps(el, op.set, env.doc.theme)]);
const styled = withProps(el, op.set, env.doc.theme);
checkTextFillPhoto(styled, env);
return replaceAll(spread, [styled]);
}
function opLock(spread: BookSpread, op: Op<'lock'>, env: ElementOpEnv): BookSpread {
+40
View File
@@ -0,0 +1,40 @@
import { addBookLockup, buildBookLockup, bookLockupBox, findBookLockup } from './book-lockups';
import { failOp, galleryPhoto, roomFor, type BookOpsContext } from './book-ops-core';
import type { BookAddLockupOp } from './book-text-ops.schema';
import type { BookElement, BookPageSetup, BookSpread } from './book.schema';
/**
* `add_lockup`: the Studio's Text styles shelf, for the assistant. The same
* builder sets the same lockup from the same words, so a lockup the assistant
* placed looks exactly like one somebody picked from the shelf.
*/
interface LockupEnv {
page: BookPageSetup;
ctx: BookOpsContext;
newId: (prefix: string) => string;
opIndex: number;
}
export function opAddLockup(spread: BookSpread, op: BookAddLockupOp, env: LockupEnv): BookSpread {
const lockup = findBookLockup(op.lockup);
if (!lockup) throw failOp('invalid_op', `no lockup ${op.lockup}`, env.opIndex);
if (op.photoId !== undefined) galleryPhoto(op.photoId, env.ctx, env.opIndex);
const box = op.box ?? bookLockupBox(lockup, env.page, spread.role !== 'inner');
const made = buildBookLockup(lockup, box, {
words: op.words,
palette: { ...(op.ink ? { ink: op.ink } : {}), ...(op.accent ? { accent: op.accent } : {}) },
photoId: op.photoId ?? null,
newId: env.newId,
});
if (!made.length) {
throw failOp('text_required', `give the words for at least one of the texts of ${lockup.id}`, env.opIndex);
}
roomFor(spread, made.length, env.opIndex);
return addBookLockup(spread, made, env.newId('g'));
}
/** A text's photo fill names a gallery photo, checked as a photo frame's photo is. */
export function checkTextFillPhoto(el: BookElement, env: { ctx: BookOpsContext; opIndex: number }): void {
if (el.kind === 'text' && el.fillPaint?.type === 'photo') galleryPhoto(el.fillPaint.photoId, env.ctx, env.opIndex);
}
+2
View File
@@ -415,6 +415,8 @@ export const BOOK_FINDING_CODES = [
'raster_effect',
'hairline',
'edge_unused',
'curve_overflow',
'effect_heavy',
] as const;
export type BookFindingCode = (typeof BOOK_FINDING_CODES)[number];
+3
View File
@@ -26,6 +26,7 @@ import { applyGroupOp, isGroupOp } from './book-ops-groups';
import { BOOK_OP_LIMITS, bookPageOpSchema, type BookPageOp } from './book-ops.schema';
import { withSpreadPaint } from './book-paint';
import { applySavedBookLayout, bookLayoutRole } from './book-saved-layouts';
import { fitSpreadTexts } from './book-text-fit';
import {
MAX_SPREAD_ELEMENTS,
type BookDocument,
@@ -92,6 +93,8 @@ export function applyBookOps(
});
// A removal or a layout can leave a group with one member: it goes, with its name.
spread = tidyGroups(spread);
// A text set to fit its frame is solved once the page's operations are done (book-text-fit.ts).
spread = fitSpreadTexts(spread, original);
checkedSpread(spread, null);
const spreads = doc.spreads.slice();
spreads[index] = spread;
+4 -1
View File
@@ -40,8 +40,11 @@ import type { BookDocument } from './book.schema';
* book-shapes-more.ts) and the line kind (book-line.schema.ts).
* - 8: themes (the document's `theme`, and the `token`, `strokeToken`, `accentToken`,
* `fontRole` and `backgroundToken` links, theme/book-theme.schema.ts).
* - 9: text effects and fitting (`transform`, `vAlign`, `outline`, `shadow`, `highlight`,
* `curve`, `fillPaint`, `fit`, `fitMax`, book-text-effects.schema.ts), text up to 400 pt and
* tracking up to 1.5 em.
*/
export const BOOK_SCHEMA_REV = 8;
export const BOOK_SCHEMA_REV = 9;
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
+46
View File
@@ -0,0 +1,46 @@
import { bookTextCurvePath } from './book-text-curve';
import { describe, expect, it } from 'vitest';
/** The line a curved text runs along, in the millimetres of its frame. */
describe('the path of a curved text', () => {
it('BKCRV-001: runs a flat arc as a straight line across the frame, inset by half an em', () => {
const p = bookTextCurvePath({ type: 'arc', bend: 0, start: 0 }, 100, 40, 10);
expect(p.d).toBe('M 5 23.5 L 95 23.5');
expect(p.length).toBe(90);
});
it('BKCRV-002: arches clockwise over the top for a positive bend and dips anticlockwise for a negative one', () => {
const up = bookTextCurvePath({ type: 'arc', bend: 50, start: 0 }, 100, 60, 10);
const down = bookTextCurvePath({ type: 'arc', bend: -50, start: 0 }, 100, 60, 10);
expect(up.d).toMatch(/ 0 0 1 95 /);
expect(down.d).toMatch(/ 0 0 0 95 /);
// Longer than the chord, the same either way.
expect(up.length).toBeGreaterThan(90);
expect(up.length).toBeCloseTo(down.length, 6);
});
it('BKCRV-003: bends a half circle at 100, whose length is half the circumference of the chord', () => {
const p = bookTextCurvePath({ type: 'arc', bend: 100, start: 0 }, 120, 80, 10);
expect(p.length).toBeCloseTo((Math.PI * 110) / 2, 4);
});
it('BKCRV-004: keeps a bend past the bounds to a half circle', () => {
const half = bookTextCurvePath({ type: 'arc', bend: 100, start: 0 }, 120, 80, 10);
expect(bookTextCurvePath({ type: 'arc', bend: 300, start: 0 }, 120, 80, 10)).toEqual(half);
});
it('BKCRV-005: runs a circle round the inscribed circle, brought in by the letters, starting half a turn before its centre', () => {
const p = bookTextCurvePath({ type: 'circle', bend: 0, start: 0 }, 100, 100, 8);
const r = 50 - 8 * 0.75;
expect(p.length).toBeCloseTo(2 * Math.PI * r, 6);
// From the bottom of the circle, so the middle of the words lands at the top.
expect(p.d.startsWith(`M 50 ${50 + r}`)).toBe(true);
expect(p.d.match(/ A /g)).toHaveLength(2);
});
it('BKCRV-006: never draws a circle smaller than a millimetre, whatever the letters', () => {
expect(bookTextCurvePath({ type: 'circle', bend: 0, start: 90 }, 4, 4, 30).length).toBeCloseTo(2 * Math.PI, 6);
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { BookTextCurve } from './book-text-effects.schema';
/**
* The line a curved text runs along, in the millimetres of its frame.
*
* Both the page and the checks read it: the page lays the words on it with SVG
* `textPath`, the checks compare its length with what the words need. It is
* computed here rather than in the renderer so the two can never disagree on
* how long a line is.
*
* The frame's SVG is drawn with a viewBox of its own size in millimetres, so a
* user unit is a millimetre in both directions and an arc command means a
* circle, never the ellipse it would turn into under a non-uniform scale.
*/
export interface BookTextCurvePath {
/** SVG path data, in millimetres from the frame's top left. */
d: string;
/** The length of the line in millimetres. */
length: number;
}
const r2 = (v: number) => Math.round(v * 100) / 100;
/** A point on a circle at `deg` degrees, 0 at the top and clockwise, as the page draws it. */
function onCircle(cx: number, cy: number, r: number, deg: number): string {
const rad = (deg * Math.PI) / 180;
return `${r2(cx + r * Math.sin(rad))} ${r2(cy - r * Math.cos(rad))}`;
}
/**
* Where a curved text is laid.
*
* `arc`: a chord across the frame, inset by half an em on each side so the
* first and last letter stay inside it, bowed by `bend` percent of half the
* chord (a half circle at 100). Up is the arch of a rainbow, down a smile; the
* letters always stand on the outside of the bow as read left to right, so
* neither direction prints a word upside down. The bow is centred in the frame
* with room for the letters above and below it.
*
* `circle`: the frame's inscribed circle, its radius brought in by the cap
* height so the letters stand inside the frame, starting half a turn before
* `start` so that the middle of the words, where they are anchored, sits at
* `start`.
*/
export function bookTextCurvePath(curve: BookTextCurve, w: number, h: number, fontMm: number): BookTextCurvePath {
if (curve.type === 'circle') {
const r = Math.max(1, Math.min(w, h) / 2 - fontMm * 0.75);
const from = curve.start - 180;
const d = `M ${onCircle(w / 2, h / 2, r, from)} A ${r2(r)} ${r2(r)} 0 1 1 ${onCircle(w / 2, h / 2, r, from + 180)} A ${r2(r)} ${r2(r)} 0 1 1 ${onCircle(w / 2, h / 2, r, from + 360)}`;
return { d, length: 2 * Math.PI * r };
}
const inset = Math.min(fontMm * 0.5, w * 0.1);
const chord = Math.max(1, w - inset * 2);
const x0 = r2(inset);
const x1 = r2(inset + chord);
const sag = (Math.max(-100, Math.min(100, curve.bend)) / 100) * (chord / 2);
// The baseline through the middle of the frame, moved down by the bow so the
// whole curve and the letters on it sit centred.
const mid = h / 2 + fontMm * 0.35;
if (Math.abs(sag) < 0.01) {
const y = r2(mid);
return { d: `M ${x0} ${y} L ${x1} ${y}`, length: chord };
}
const ends = r2(mid + sag / 2);
const radius = (chord * chord) / 4 / (2 * Math.abs(sag)) + Math.abs(sag) / 2;
// An arch runs clockwise over the top, a smile anticlockwise under it.
const sweep = sag > 0 ? 1 : 0;
const length = 2 * radius * Math.asin(Math.min(1, chord / (2 * radius)));
return { d: `M ${x0} ${ends} A ${r2(radius)} ${r2(radius)} 0 0 ${sweep} ${x1} ${ends}`, length };
}
+35
View File
@@ -0,0 +1,35 @@
import type { BookTextEffects } from './book-text-effects.schema';
/**
* A text's effects in words, for `describe_book_page`: what a model that
* cannot see the page needs to know to keep or change them, in the terms the
* `style` operation sets them by. Empty for a text without any.
*/
const n = (v: number) => `${Math.round(v * 10) / 10}`;
const CASES: Record<string, string> = { upper: 'capitals', lower: 'lower case', smallcaps: 'small caps' };
function fillWords(fill: NonNullable<BookTextEffects['fillPaint']>): string {
if (fill.type === 'photo') {
return `letters filled with photo ${fill.photoId} (focal ${fill.focalX.toFixed(2)}/${fill.focalY.toFixed(2)}, zoom ${n(fill.zoom)})`;
}
return fill.alpha === 0 ? 'hollow letters (outline only)' : `fill ${fill.color} at alpha ${n(fill.alpha)}`;
}
export function bookTextEffectWords(el: BookTextEffects & { size: number }): string {
const parts: string[] = [];
const cased = el.transform ? CASES[el.transform] : undefined;
if (cased) parts.push(cased);
if (el.vAlign && el.vAlign !== 'top') parts.push(`set at the ${el.vAlign} of its frame`);
if (el.fillPaint) parts.push(fillWords(el.fillPaint));
if (el.outline) parts.push(`outline ${el.outline.color} ${n(el.outline.width)} mm`);
if (el.shadow) {
parts.push(`hard shadow ${el.shadow.color} offset ${n(el.shadow.dx)}/${n(el.shadow.dy)} mm`);
}
if (el.highlight) parts.push(`on a band of ${el.highlight.color}`);
if (el.curve?.type === 'arc') parts.push(`on an arc, bend ${n(el.curve.bend)}`);
if (el.curve?.type === 'circle') parts.push(`round a circle, centred at ${n(el.curve.start)} deg`);
if (el.fit === 'shrink') parts.push(`shrunk to fit (chosen ${n(el.fitMax ?? el.size)} pt)`);
return parts.join(', ');
}
@@ -0,0 +1,77 @@
import { bookTextIsDrawn } from './book-text-effects.schema';
import { bookTextElementSchema } from './book.schema';
import { describe, expect, it } from 'vitest';
/**
* The text effects as the document stores them: absent on every text written
* before they existed, each one a value or null, and the bounds the print
* renderer depends on.
*/
const frame = { x: 10, y: 10, w: 80, h: 30 };
const parse = (over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({ id: 't1', kind: 'text', frame, text: 'Lisbon', ...over });
describe('the effect fields of a text', () => {
it('BKFX-001: leave a text from before them exactly as it was, with no new key', () => {
const el = parse();
for (const key of [
'transform',
'vAlign',
'outline',
'shadow',
'highlight',
'curve',
'fillPaint',
'fit',
'fitMax',
]) {
expect(key in el, key).toBe(false);
}
});
it('BKFX-002: read every effect, fill their defaults in, and take null for an effect taken off', () => {
const el = parse({
transform: 'upper',
vAlign: 'middle',
outline: { color: '#ffffff', width: 0.4 },
shadow: { dx: 1, dy: 1, color: '#111111' },
highlight: { color: '#c2410c' },
curve: { type: 'arc' },
fillPaint: { type: 'photo', photoId: 12 },
fit: 'shrink',
fitMax: 120,
});
expect(el.shadow?.alpha).toBe(1);
expect(el.highlight).toEqual({ color: '#c2410c', alpha: 1, padX: 1.2, padY: 0.3, radius: 0 });
expect(el.curve).toEqual({ type: 'arc', bend: 40, start: 0 });
expect(el.fillPaint).toEqual({ type: 'photo', photoId: 12, focalX: 0.5, focalY: 0.5, zoom: 1 });
expect(parse({ outline: null, curve: null }).outline).toBeNull();
});
it('BKFX-003: set a word across a cover and capitals spaced to one and a half em, and no further', () => {
expect(parse({ size: 400, tracking: 1.5 }).size).toBe(400);
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, size: 401 }).success).toBe(false);
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, tracking: 1.6 }).success).toBe(false);
expect(
bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, shadow: { dx: 1, dy: 1, color: 'red' } }).success,
).toBe(false);
expect(
bookTextElementSchema.safeParse({
id: 't',
kind: 'text',
frame,
fillPaint: { type: 'pattern', color: '#111111' },
}).success,
).toBe(false);
});
it('BKFX-004: draws a curved or a photo-filled text as SVG, and every other one as type', () => {
expect(bookTextIsDrawn(parse())).toBe(false);
expect(bookTextIsDrawn(parse({ outline: { color: '#111111', width: 1 } }))).toBe(false);
expect(bookTextIsDrawn(parse({ fillPaint: { type: 'solid', color: '#111111', alpha: 0 } }))).toBe(false);
expect(bookTextIsDrawn(parse({ curve: { type: 'circle' } }))).toBe(true);
expect(bookTextIsDrawn(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBe(true);
});
});
+142
View File
@@ -0,0 +1,142 @@
import { z } from 'zod';
/**
* What a text can be besides words in a face: outlined, given a hard shadow,
* set on a marker band, bent along an arc or round a circle, filled with a
* photograph, set in capitals or small caps, sat at the top, middle or foot of
* its frame, and shrunk to fit it.
*
* ── Why every field is optional ─────────────────────────────────────────────
*
* The fields are merged into the text element (book.schema.ts) and none of
* them has a default. A text written before they existed parses to exactly
* the object it was, so the stored book does not grow a key on its next save,
* and an absent field means what it always meant: no effect. Removing an
* effect is `null`, which reads the same as absent.
*
* ── Why each one is drawn the way it is ────────────────────────────────────
*
* All of them print as vectors (TextEffectsView.tsx in the client): the shadow
* is a second copy of the words, offset, never a blur; the outline is a
* stroked copy laid under the fill, so it grows outwards and never eats the
* letter; the band is a span background; a curve is SVG `textPath` on a path
* computed in millimetres; a photo fill is the words as an SVG clip over an
* `<image>`, which the print view waits for. Nothing here asks the renderer to
* measure, so the page stays synchronous: a fitted size is solved when the
* text is edited and stored (book-text-fit.ts).
*
* ── The fill and the paint system ──────────────────────────────────────────
*
* `fillPaint` takes a solid colour with its own alpha or a photograph today.
* Its solid variant has the shape the book's paint type gives a solid fill, so
* gradients and patterns join this union as further members, without a
* reader of today's fields changing.
*/
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
const alpha = z.number().min(0).max(1);
/** How the letters are cased. `smallcaps` sets the lower case as small capitals. */
export const BOOK_TEXT_TRANSFORMS = ['none', 'upper', 'lower', 'smallcaps'] as const;
export type BookTextTransform = (typeof BOOK_TEXT_TRANSFORMS)[number];
/** Where the lines sit in the frame, top to bottom. */
export const BOOK_TEXT_VALIGNS = ['top', 'middle', 'bottom'] as const;
export type BookTextVAlign = (typeof BOOK_TEXT_VALIGNS)[number];
/** A line round each letter. `width` is millimetres outside the letter. */
export const bookTextOutlineSchema = z.object({
color: hex,
width: z.number().min(0.05).max(6),
});
export type BookTextOutline = z.infer<typeof bookTextOutlineSchema>;
/** A hard shadow: the words again, offset by `dx`/`dy` millimetres behind them. No blur, ever. */
export const bookTextShadowSchema = z.object({
dx: z.number().min(-30).max(30),
dy: z.number().min(-30).max(30),
color: hex,
alpha: alpha.default(1),
});
export type BookTextShadow = z.infer<typeof bookTextShadowSchema>;
/** A marker band behind each line of words, `padX`/`padY` millimetres round them. */
export const bookTextHighlightSchema = z.object({
color: hex,
alpha: alpha.default(1),
padX: z.number().min(0).max(20).default(1.2),
padY: z.number().min(0).max(20).default(0.3),
radius: z.number().min(0).max(20).default(0),
});
export type BookTextHighlight = z.infer<typeof bookTextHighlightSchema>;
/**
* Words on a curve. `arc` bends a line across the frame, up for a positive
* `bend` and down for a negative one, a half circle at 100; `circle` runs the
* words round the frame's inscribed circle with their middle at `start`
* degrees, 0 at the top and clockwise.
*/
export const BOOK_TEXT_CURVES = ['arc', 'circle'] as const;
export const bookTextCurveSchema = z.object({
type: z.enum(BOOK_TEXT_CURVES),
bend: z.number().min(-100).max(100).default(40),
start: z.number().min(-180).max(180).default(0),
});
export type BookTextCurve = z.infer<typeof bookTextCurveSchema>;
/** What fills the letters in place of `color`: a colour with its own alpha (0 leaves an outline only), or a photograph. */
export const bookTextFillSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('solid'), color: hex, alpha: alpha.default(1) }),
z.object({
type: z.literal('photo'),
/** `trek_photos.id`, as a photo element keeps it. */
photoId: z.number().int().positive(),
focalX: z.number().min(0).max(1).default(0.5),
focalY: z.number().min(0).max(1).default(0.5),
zoom: z.number().min(1).max(4).default(1),
}),
]);
export type BookTextFill = z.infer<typeof bookTextFillSchema>;
/** `shrink` sets the text at the largest size up to `fitMax` that holds the frame, solved on edit and stored in `size`. */
export const BOOK_TEXT_FITS = ['none', 'shrink'] as const;
/** The largest text size, in points: a word across a whole cover. */
export const MAX_BOOK_TEXT_SIZE = 400;
/** The effect fields of a text element, merged into its schema. */
export const BOOK_TEXT_EFFECT_FIELDS = {
transform: z.enum(BOOK_TEXT_TRANSFORMS).optional(),
vAlign: z.enum(BOOK_TEXT_VALIGNS).optional(),
outline: bookTextOutlineSchema.nullable().optional(),
shadow: bookTextShadowSchema.nullable().optional(),
highlight: bookTextHighlightSchema.nullable().optional(),
curve: bookTextCurveSchema.nullable().optional(),
fillPaint: bookTextFillSchema.nullable().optional(),
fit: z.enum(BOOK_TEXT_FITS).optional(),
/** The size a fitted text is set at when its frame holds it: the size the person chose. */
fitMax: z.number().min(4).max(MAX_BOOK_TEXT_SIZE).optional(),
};
/** The effect fields as one object, what a renderer or a check reads off a text. */
export interface BookTextEffects {
transform?: BookTextTransform;
vAlign?: BookTextVAlign;
outline?: BookTextOutline | null;
shadow?: BookTextShadow | null;
highlight?: BookTextHighlight | null;
curve?: BookTextCurve | null;
fillPaint?: BookTextFill | null;
fit?: (typeof BOOK_TEXT_FITS)[number];
fitMax?: number;
}
/**
* Whether a text is drawn as SVG rather than as a box of HTML type: on a
* curve, or filled with a photograph. Such a text is set line by line as
* written, never wrapped, which is what word art wants and what the checks
* measure it by.
*/
export function bookTextIsDrawn(el: BookTextEffects): boolean {
return !!el.curve || el.fillPaint?.type === 'photo';
}
+106
View File
@@ -0,0 +1,106 @@
import { spreadFindings } from './book-findings';
import { designFindings } from './book-findings-design';
import { bookTextHasOwnGround, textEffectFindings } from './book-text-findings';
import { bookPageSetupSchema, bookTextElementSchema, type BookSpread, type BookTextElement } from './book.schema';
import { describe, expect, it } from 'vitest';
/** What the checks say about text effects, and what they leave to them. */
const page = bookPageSetupSchema.parse({});
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({
id: 't1',
kind: 'text',
frame: { x: 230, y: 40, w: 120, h: 40 },
text: 'Lisbon',
size: 30,
...over,
});
const spreadOf = (...elements: BookTextElement[]): BookSpread => ({
id: 'sp',
role: 'inner',
background: null,
elements,
parked: [],
entryId: null,
});
const codes = (list: { code: string }[]) => list.map((f) => f.code);
describe('the checks of a text effect', () => {
it('BKFXF-001: says when the words run past the end of their curve, and nothing when they fit', () => {
const long = text({
text: 'A postmark from the far end of the coast road',
curve: { type: 'arc', bend: 30, start: 0 },
});
expect(codes(textEffectFindings(spreadOf(long)))).toEqual(['curve_overflow']);
const fits = text({ curve: { type: 'arc', bend: 30, start: 0 } });
expect(textEffectFindings(spreadOf(fits))).toEqual([]);
});
it('BKFXF-002: measures photo-filled word art as written, and the general overflow check leaves it alone', () => {
const wide = text({ text: 'LISBON AND PORTO', size: 90, fillPaint: { type: 'photo', photoId: 2 } });
expect(codes(textEffectFindings(spreadOf(wide)))).toEqual(['text_overflow']);
const overflows = spreadFindings(spreadOf(wide), page).filter((f) => f.code === 'text_overflow');
expect(overflows).toHaveLength(1);
expect(overflows[0]?.message).toContain('without wrapping');
});
it('BKFXF-003: warns about an outline heavy enough to close the letters', () => {
const heavy = text({ outline: { color: '#111111', width: 2 } });
expect(codes(textEffectFindings(spreadOf(heavy)))).toEqual(['effect_heavy']);
expect(textEffectFindings(spreadOf(text({ outline: { color: '#111111', width: 0.4 } })))).toEqual([]);
});
it('BKFXF-004: checks the words against their own band rather than against the page', () => {
const poor = text({ color: '#777777', highlight: { color: '#666666' } });
expect(codes(textEffectFindings(spreadOf(poor)))).toEqual(['contrast_low']);
const good = text({ color: '#ffffff', highlight: { color: '#111111' } });
expect(textEffectFindings(spreadOf(good))).toEqual([]);
});
it('BKFXF-005: checks a photo fill as a photo frame is checked: in the gallery, shown, sharp enough', () => {
const filled = text({ fillPaint: { type: 'photo', photoId: 7 } });
expect(codes(textEffectFindings(spreadOf(filled), [{ photoId: 8 }]))).toEqual(['not_in_gallery']);
expect(codes(textEffectFindings(spreadOf(filled), [{ photoId: 7, hidden: true }]))).toEqual(['hidden_photo']);
expect(codes(textEffectFindings(spreadOf(filled), [{ photoId: 7, width: 400, height: 300 }]))).toEqual(['low_dpi']);
expect(textEffectFindings(spreadOf(filled), [{ photoId: 7, width: 4000, height: 3000 }])).toEqual([]);
expect(textEffectFindings(spreadOf(filled))).toEqual([]);
});
it('BKFXF-006: skips an empty text', () => {
expect(textEffectFindings(spreadOf(text({ text: ' ', outline: { color: '#111111', width: 5 } })))).toEqual([]);
});
});
describe('a text that brings its own ground', () => {
it('BKFXF-007: is a text on a band, filled with a photo, hollow, or with a real outline', () => {
expect(bookTextHasOwnGround(text())).toBe(false);
expect(bookTextHasOwnGround(text({ highlight: { color: '#111111' } }))).toBe(true);
expect(bookTextHasOwnGround(text({ fillPaint: { type: 'photo', photoId: 1 } }))).toBe(true);
expect(bookTextHasOwnGround(text({ fillPaint: { type: 'solid', color: '#111111', alpha: 0 } }))).toBe(true);
expect(bookTextHasOwnGround(text({ fillPaint: { type: 'solid', color: '#111111', alpha: 1 } }))).toBe(false);
expect(bookTextHasOwnGround(text({ outline: { color: '#111111', width: 0.5 } }))).toBe(true);
expect(bookTextHasOwnGround(text({ outline: { color: '#111111', width: 0.1 } }))).toBe(false);
});
it('BKFXF-008: is left out of the page contrast check, which reads it against the page otherwise', () => {
const pale = text({ color: '#f4f4f4' });
expect(codes(designFindings(spreadOf(pale), page))).toContain('contrast_low');
const outlined = text({ color: '#f4f4f4', outline: { color: '#111111', width: 0.6 } });
expect(codes(designFindings(spreadOf(outlined), page))).not.toContain('contrast_low');
});
it('BKFXF-009: a curved text is not compared for overlap by its frame', () => {
const ring = text({
id: 'ring',
frame: { x: 230, y: 20, w: 100, h: 100 },
curve: { type: 'circle', bend: 0, start: 0 },
});
const middle = text({ id: 'mid', frame: { x: 250, y: 50, w: 60, h: 40 }, size: 12 });
expect(codes(spreadFindings(spreadOf(ring, middle), page))).not.toContain('overlap');
});
});
+133
View File
@@ -0,0 +1,133 @@
import { contrastRatio, requiredContrast } from './book-colour';
import type { BookPhotoInfo } from './book-ops-core';
import type { BookFinding } from './book-ops.schema';
import { bookTextCurvePath } from './book-text-curve';
import { bookTextIsDrawn } from './book-text-effects.schema';
import { bookDrawnLines, bookTextRunMm } from './book-text-fit';
import type { BookSpread, BookTextElement } from './book.schema';
/**
* What can go wrong with a text effect, as a proof would show it: words that
* run past the end of their curve, word art wider than its frame, an outline
* so heavy it closes the letters, a band the words do not stand out on, and a
* photo fill that is missing, not shown or too small for its letters.
*
* Joined into the page's print findings (`spreadFindings`), so the Studio's
* assistant reads them beside every write and in `describe_book_page`.
*/
const n = (v: number) => `${Math.round(v * 10) / 10}`;
const ptToMm = (pt: number) => (pt * 25.4) / 72;
/** Below this many pixels per printed millimetre a photo fill looks soft: the 200 dpi of a photo. */
const MIN_FILL_PX_PER_MM = 200 / 25.4;
/**
* Whether a text brings its own ground, so the page under it does not decide
* whether it reads: a band behind it, a real outline, or letters filled with a
* picture or left hollow. The contrast and busy checks leave such a text to
* the checks here.
*/
export function bookTextHasOwnGround(el: BookTextElement): boolean {
if (el.highlight) return true;
if (el.fillPaint && (el.fillPaint.type === 'photo' || el.fillPaint.alpha < 0.5)) return true;
return !!el.outline && el.outline.width >= ptToMm(el.size) * 0.04;
}
function curveRoom(el: BookTextElement, out: BookFinding[]): void {
if (!el.curve) return;
const line = bookDrawnLines(el)[0] ?? '';
const need = bookTextRunMm(el, line);
const path = bookTextCurvePath(el.curve, el.frame.w, el.frame.h, ptToMm(el.size));
if (need <= path.length) return;
out.push({
code: 'curve_overflow',
severity: 'warn',
elementIds: [el.id],
message: `the curved text ${el.id} needs about ${n(need)} mm and its ${el.curve.type} is ${n(path.length)} mm long; the end of the words is cut off. Set it smaller, shorten it or enlarge the frame`,
});
}
function drawnRoom(el: BookTextElement, out: BookFinding[]): void {
if (el.curve || !bookTextIsDrawn(el)) return;
const lines = bookDrawnLines(el);
const widest = Math.max(0, ...lines.map((l) => bookTextRunMm(el, l)));
const tall = lines.length * ptToMm(el.size) * el.leading;
if (widest <= el.frame.w * 1.05 && tall <= el.frame.h * 1.1) return;
out.push({
code: 'text_overflow',
severity: 'severe',
elementIds: [el.id],
message: `the photo-filled text ${el.id} is set as written, without wrapping, and needs about ${n(widest)} x ${n(tall)} mm in a ${n(el.frame.w)} x ${n(el.frame.h)} mm frame; break the line, set it smaller or use fit shrink`,
});
}
function heavyOutline(el: BookTextElement, out: BookFinding[]): void {
if (!el.outline) return;
const share = el.outline.width / ptToMm(el.size);
if (share <= 0.12) return;
out.push({
code: 'effect_heavy',
severity: 'warn',
elementIds: [el.id],
message: `the outline of text ${el.id} is ${n(el.outline.width)} mm on ${n(el.size)} pt letters, heavy enough to close their counters; keep it under about a tenth of the letter height`,
});
}
function bandContrast(el: BookTextElement, out: BookFinding[]): void {
if (!el.highlight || el.highlight.alpha < 0.6 || el.fillPaint) return;
const ratio = contrastRatio(el.color, el.highlight.color);
const need = requiredContrast(el.size);
if (ratio >= need) return;
out.push({
code: 'contrast_low',
severity: ratio < 2 ? 'severe' : 'warn',
elementIds: [el.id],
message: `text ${el.id} in ${el.color} on its band of ${el.highlight.color} has a contrast of ${ratio.toFixed(1)}:1, under the ${need}:1 type of that size needs`,
});
}
function photoFill(el: BookTextElement, photos: readonly BookPhotoInfo[] | undefined, out: BookFinding[]): void {
if (el.fillPaint?.type !== 'photo') return;
const { photoId, zoom } = el.fillPaint;
const info = photos?.find((p) => p.photoId === photoId);
if (photos && !info) {
out.push({
code: 'not_in_gallery',
severity: 'warn',
elementIds: [el.id],
message: `photo ${photoId} filling text ${el.id} is not in this journey's gallery`,
});
}
if (info?.hidden) {
out.push({
code: 'hidden_photo',
severity: 'info',
elementIds: [el.id],
message: `photo ${photoId} filling text ${el.id} is by another contributor and is not shown in the page image`,
});
}
if (!info?.width || !info.height) return;
const pxPerMm = Math.min(info.width / (el.frame.w * zoom), info.height / (el.frame.h * zoom));
if (pxPerMm >= MIN_FILL_PX_PER_MM) return;
out.push({
code: 'low_dpi',
severity: 'warn',
elementIds: [el.id],
message: `photo ${photoId} filling text ${el.id} prints at about ${Math.round(pxPerMm * 25.4)} dpi; zoom it out or make the frame smaller`,
});
}
/** The findings of every text effect on a page. */
export function textEffectFindings(spread: BookSpread, photos?: readonly BookPhotoInfo[]): BookFinding[] {
const out: BookFinding[] = [];
for (const el of spread.elements) {
if (el.kind !== 'text' || !el.text.trim()) continue;
curveRoom(el, out);
drawnRoom(el, out);
heavyOutline(el, out);
bandContrast(el, out);
photoFill(el, photos, out);
}
return out.map((f) => ({ ...f, message: f.message.slice(0, 300) }));
}
+131
View File
@@ -0,0 +1,131 @@
import { textHeightNeeded } from './book-findings';
import { blankBook } from './book-pages';
import {
bookCasedText,
bookDrawnLines,
bookTextFits,
fitBookTexts,
fitSpreadTexts,
solveBookTextFit,
} from './book-text-fit';
import { bookTextElementSchema, type BookSpread, type BookTextElement } from './book.schema';
import { describe, expect, it } from 'vitest';
/** Text set to fit its frame: solved on edit, stored in `size`, the choice kept in `fitMax`. */
const text = (over: Record<string, unknown> = {}): BookTextElement =>
bookTextElementSchema.parse({
id: 't1',
kind: 'text',
frame: { x: 0, y: 0, w: 80, h: 20 },
text: 'Lisbon',
size: 40,
leading: 1,
...over,
});
const spreadOf = (...elements: BookTextElement[]): BookSpread => ({
id: 'sp',
role: 'inner',
background: null,
elements,
parked: [],
entryId: null,
});
describe('how a text is cased and lined up for drawing', () => {
it('BKFIT-001: cases the words as the page shows them, and joins a curved text into one line', () => {
expect(bookCasedText({ text: 'Rua Augusta', transform: 'upper' })).toBe('RUA AUGUSTA');
expect(bookCasedText({ text: 'Rua Augusta', transform: 'lower' })).toBe('rua augusta');
expect(bookCasedText({ text: 'Rua Augusta', transform: 'smallcaps' })).toBe('Rua Augusta');
expect(bookDrawnLines({ text: 'One\n\nTwo ' })).toEqual(['One', '', 'Two']);
expect(bookDrawnLines({ text: 'One\n\nTwo', curve: { type: 'arc', bend: 10, start: 0 } })).toEqual(['One Two']);
});
});
describe('the fit solver', () => {
it('BKFIT-002: keeps the chosen size when the words hold the frame at it', () => {
const el = text({ size: 20 });
expect(bookTextFits(el)).toBe(true);
expect(solveBookTextFit(el)).toBe(20);
});
it('BKFIT-003: finds the largest quarter point that holds a frame too small, and the overflow check agrees', () => {
const el = text({ text: 'Two weeks along the Portuguese coast', size: 60 });
expect(bookTextFits(el)).toBe(false);
const size = solveBookTextFit(el);
expect(size).toBeLessThan(60);
expect(size * 4).toBe(Math.round(size * 4));
expect(bookTextFits({ ...el, size })).toBe(true);
expect(bookTextFits({ ...el, size: size + 0.25 })).toBe(false);
expect(textHeightNeeded({ ...el, size })).toBeLessThanOrEqual(el.frame.h * 1.02);
});
it('BKFIT-004: never sets a word wider than its frame, even when the height would allow it', () => {
const el = text({ text: 'Mediterranean', frame: { x: 0, y: 0, w: 40, h: 200 }, size: 80 });
expect(solveBookTextFit(el)).toBeLessThan(20);
});
it('BKFIT-005: stops at the document floor of 4 pt', () => {
const el = text({
text: 'A very long sentence that will never fit in this small frame',
frame: { x: 0, y: 0, w: 5, h: 2 },
});
expect(solveBookTextFit(el)).toBe(4);
});
it('BKFIT-006: measures a curved text along its curve and a photo-filled one by its widest line', () => {
const curved = text({
text: 'Postmark from Lisbon',
curve: { type: 'circle', bend: 0, start: 0 },
frame: { x: 0, y: 0, w: 60, h: 60 },
});
const size = solveBookTextFit(curved);
expect(bookTextFits({ ...curved, size })).toBe(true);
const filled = text({ text: 'LISBON\nPORTO', fillPaint: { type: 'photo', photoId: 2 }, size: 200 });
expect(solveBookTextFit(filled)).toBeLessThan(200);
expect(bookTextFits({ ...filled, text: ' ' })).toBe(true);
});
});
describe('fitting a page', () => {
it('BKFIT-007: solves only the fitted texts and hands the same page back when nothing moved', () => {
const plain = text({ id: 'plain', text: 'Two weeks along the Portuguese coast', size: 60 });
const fit = text({ id: 'fit', text: 'Two weeks along the Portuguese coast', size: 60, fit: 'shrink' });
const sp = spreadOf(plain, fit);
const out = fitSpreadTexts(sp);
expect(out.elements[0]).toBe(plain);
const solved = out.elements[1] as BookTextElement;
expect(solved.size).toBeLessThan(60);
expect(solved.fitMax).toBe(60);
expect(fitSpreadTexts(out)).toBe(out);
});
it('BKFIT-008: gives the words their size back when the frame grows again', () => {
const fit = text({ text: 'Two weeks along the Portuguese coast', size: 60, fit: 'shrink' });
const small = fitSpreadTexts(spreadOf(fit));
const grown = small.elements.map((e) => ({ ...e, frame: { ...e.frame, w: 2000, h: 400 } })) as BookTextElement[];
const back = fitSpreadTexts(spreadOf(...grown), small).elements[0] as BookTextElement;
expect(back.size).toBe(60);
});
it('BKFIT-009: takes a size somebody set in the edit as the new choice', () => {
const fit = fitSpreadTexts(spreadOf(text({ size: 20, fit: 'shrink' })));
const chosen = fit.elements.map((e) => ({ ...e, size: 30 })) as BookTextElement[];
const out = fitSpreadTexts(spreadOf(...chosen), fit).elements[0] as BookTextElement;
expect(out.fitMax).toBe(30);
expect(out.size).toBe(30);
});
it('BKFIT-010: fits only the spreads an edit touched, and keeps the document when nothing changed', () => {
const doc = blankBook({ title: 'Lisbon', newId: (p) => `${p}-x` });
expect(fitBookTexts(doc, doc)).toBe(doc);
expect(fitBookTexts(doc, null)).toBe(doc);
const fit = text({ text: 'Two weeks along the Portuguese coast', size: 60, fit: 'shrink' });
const spreads = doc.spreads.map((sp, i) => (i === 0 ? { ...sp, elements: [fit] } : sp));
const next = fitBookTexts({ ...doc, spreads }, doc);
expect((next.spreads[0]?.elements[0] as BookTextElement).size).toBeLessThan(60);
expect(next.spreads[1]).toBe(doc.spreads[1]);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { bookLetterMm, textHeightNeeded } from './book-findings';
import { bookTextCurvePath } from './book-text-curve';
import { bookTextIsDrawn } from './book-text-effects.schema';
import type { BookDocument, BookElement, BookSpread, BookTextElement } from './book.schema';
/**
* Text set to fit its frame, solved when it is edited and stored.
*
* A fitted text keeps two sizes: `fitMax`, the size somebody chose, and
* `size`, the largest size up to it at which the words hold the frame. The
* renderer reads `size` like any other text, so the page stays synchronous and
* a reader from before fitting existed draws the fitted size, which is the one
* that was designed. The solver uses the same letter measure as the overflow
* check, so a text it fitted is never reported as overflowing.
*
* Who runs it: the Studio after every change to the document, and the edit
* engine after a page's operations, both through `fitSpreadTexts`. A size that
* changed in that edit is a new choice and becomes the new `fitMax`; one that
* did not keeps the old ceiling, so a frame made smaller and then larger again
* gives the words their size back.
*/
/** The smallest size the solver goes to: the document's own floor. */
const MIN_SIZE = 4;
const ptToMm = (pt: number) => (pt * 25.4) / 72;
/** The words as they are cased on the page: what the measure counts and the SVG draws. */
export function bookCasedText(el: Pick<BookTextElement, 'text' | 'transform'>): string {
if (el.transform === 'upper') return el.text.toUpperCase();
if (el.transform === 'lower') return el.text.toLowerCase();
return el.text;
}
/** The lines of a text that is drawn as written: its paragraphs, a curved text's joined into one. */
export function bookDrawnLines(el: Pick<BookTextElement, 'text' | 'transform' | 'curve'>): string[] {
const lines = bookCasedText(el)
.split('\n')
.map((l) => l.trimEnd());
return el.curve ? [lines.filter(Boolean).join(' ')] : lines;
}
/** How wide a run of characters sets, roughly, in millimetres. */
export function bookTextRunMm(el: BookTextElement, run: string): number {
return [...run].length * bookLetterMm(el);
}
/** Whether a text at its current size holds its frame, as the checks measure it. */
export function bookTextFits(el: BookTextElement): boolean {
if (!el.text.trim()) return true;
if (bookTextIsDrawn(el)) {
const lines = bookDrawnLines(el);
const widest = Math.max(0, ...lines.map((l) => bookTextRunMm(el, l)));
if (el.curve) {
const path = bookTextCurvePath(el.curve, el.frame.w, el.frame.h, ptToMm(el.size));
return widest <= path.length * 0.98;
}
return widest <= el.frame.w && lines.length * ptToMm(el.size) * el.leading <= el.frame.h * 1.02;
}
const longest = Math.max(
0,
...bookCasedText(el)
.split(/\s+/)
.map((w) => bookTextRunMm(el, w)),
);
return longest <= el.frame.w && textHeightNeeded(el) <= el.frame.h * 1.02;
}
/** The size a fitted text is set at: its ceiling when that holds, else the largest quarter point that does. */
export function solveBookTextFit(el: BookTextElement, ceiling: number = el.fitMax ?? el.size): number {
const top = Math.max(MIN_SIZE, ceiling);
if (bookTextFits({ ...el, size: top })) return top;
let lo = MIN_SIZE * 4;
let hi = Math.floor(top * 4) - 1;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (bookTextFits({ ...el, size: mid / 4 })) lo = mid;
else hi = mid - 1;
}
return lo / 4;
}
/** One text fitted, or the same object when it is not fitted or already right. */
function fitted(el: BookElement, before: BookElement | undefined): BookElement {
if (el.kind !== 'text' || el.fit !== 'shrink') return el;
const chose = before?.kind === 'text' && before.size !== el.size;
const ceiling = chose || el.fitMax === undefined ? el.size : el.fitMax;
const size = solveBookTextFit(el, ceiling);
return size === el.size && ceiling === el.fitMax ? el : { ...el, size, fitMax: ceiling };
}
/**
* Every fitted text of a spread set at its solved size. `before` is the spread
* as it was before the edit, when there was one, to tell a size somebody chose
* from one the solver set. The same spread comes back when nothing moved.
*/
export function fitSpreadTexts(spread: BookSpread, before?: BookSpread): BookSpread {
const prior = new Map((before?.elements ?? []).map((e) => [e.id, e]));
let changed = false;
const elements = spread.elements.map((el) => {
const next = fitted(el, prior.get(el.id));
if (next !== el) changed = true;
return next;
});
return changed ? { ...spread, elements } : spread;
}
/** `fitSpreadTexts` over the spreads an edit changed, against the document before it. */
export function fitBookTexts(next: BookDocument, before: BookDocument | null): BookDocument {
const prior = new Map((before?.spreads ?? []).map((s) => [s.id, s]));
let changed = false;
const spreads = next.spreads.map((sp) => {
const was = prior.get(sp.id);
if (was === sp) return sp;
const out = fitSpreadTexts(sp, was);
if (out !== sp) changed = true;
return out;
});
return changed ? { ...next, spreads } : next;
}
+63
View File
@@ -0,0 +1,63 @@
import { idSchema } from '../common/primitives.schema';
import { BOOK_LOCKUP_ROLES } from './book-lockups';
import { BOOK_LOCKUPS } from './book-lockups.data';
import { BOOK_TEXT_EFFECT_FIELDS } from './book-text-effects.schema';
import { z } from 'zod';
/**
* The assistant's side of the text effects and the lockups: the effect fields
* a `style` or an `add` of a text may set, and the `add_lockup` operation.
* The values are the document's own (book-text-effects.schema.ts); only a line
* on what each means is added, since the tool schema is all a model reads.
*/
const f = BOOK_TEXT_EFFECT_FIELDS;
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
/** The effect fields of a text's style, each left as it is when not named; null takes an effect off. */
export const TEXT_EFFECT_PROPS = {
transform: f.transform,
vAlign: f.vAlign,
outline: f.outline.describe('Width in mm outside the letters.'),
shadow: f.shadow.describe('Hard, never blurred; dx, dy in mm.'),
highlight: f.highlight.describe('A marker band behind each line; pads in mm.'),
curve: f.curve.describe('One line on an arc (bend > 0 arches up) or round a circle (start: degrees, 0 top).'),
fillPaint: f.fillPaint.describe('Replaces color: solid (alpha 0 is hollow, with an outline) or a gallery photo.'),
fit: f.fit.describe('shrink: the largest size up to size that holds the frame.'),
};
const lockupIds = BOOK_LOCKUPS.map((l) => l.id) as [string, ...string[]];
/** Every lockup by id; the ids say what each sets. */
export const bookLockupIdSchema = z
.enum(lockupIds)
.describe('A set title, badge, postmark, quote, caption or chapter mark.');
/** The words of a lockup, by the role each plays. */
export const bookLockupWordsSchema = z
.strictObject(Object.fromEntries(BOOK_LOCKUP_ROLES.map((r) => [r, z.string().max(200).optional()])))
.describe('Words by role (label as "Day"); a part without words is left out.');
/**
* Set a lockup: a title, badge, postmark, quote, caption or chapter mark made
* of a few texts and shapes that belong together. Placed in `box`, or at its
* own size centred on the page; every text in it fits its frame.
*/
export const bookAddLockupOpSchema = z.strictObject({
op: z.literal('add_lockup'),
lockup: bookLockupIdSchema,
words: bookLockupWordsSchema,
box: z
.strictObject({
x: z.number().finite().min(-10000).max(10000),
y: z.number().finite().min(-10000).max(10000),
w: z.number().finite().min(1).max(10000),
h: z.number().finite().min(1).max(10000),
})
.optional(),
ink: hex.optional(),
accent: hex.describe('Best taken from the photos.').optional(),
photoId: idSchema.describe('Fills the letters of photo-word.').optional(),
});
export type BookAddLockupOp = z.infer<typeof bookAddLockupOpSchema>;
+249
View File
@@ -0,0 +1,249 @@
import { applyBookOps } from './book-ops';
import type { BookOpsContext } from './book-ops-core';
import type { BookPageOpInput } from './book-ops.schema';
import { blankBook } from './book-pages';
import { bookAddLockupOpSchema } from './book-text-ops.schema';
import {
bookSpreadSchema,
bookTextElementSchema,
type BookDocument,
type BookElement,
type BookSpread,
type BookTextElement,
} from './book.schema';
import { describeSpread } from './describe-spread';
import { describe, expect, it } from 'vitest';
/**
* The assistant's side of the text effects and the lockups: `style` and `add`
* set every effect the inspector sets, `add_lockup` places what the shelf
* places, a fitted text is solved after the operations, and the page in words
* says what each text carries.
*/
let seq = 0;
const newId = (p: string) => `${p}-n${++seq}`;
const text = (id: string, over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({
id,
kind: 'text',
frame: { x: 230, y: 40, w: 120, h: 30 },
text: 'Lisbon',
size: 24,
...over,
}) as BookElement;
function book(elements: BookElement[]): BookDocument {
const doc = blankBook({ title: 'Lisbon', newId: (p) => `${p}-x` });
const spreads = doc.spreads.map((s, i) => ({ ...s, id: i === 2 ? 'sp-in' : `sp-${i}` }));
spreads[2] = { ...(spreads[2] as BookSpread), elements };
return { ...doc, spreads };
}
const gallery = [{ photoId: 1, width: 4000, height: 3000 }];
const ctx: BookOpsContext = { photos: gallery };
const run = (doc: BookDocument, ops: BookPageOpInput[]) =>
applyBookOps(doc, { spreadId: 'sp-in', ops }, { newId, ...ctx });
const inner = (doc: BookDocument) => doc.spreads[2] as BookSpread;
function ok(result: ReturnType<typeof applyBookOps>): BookDocument {
if (!result.ok) throw new Error(`refused: ${result.error.code} ${result.error.message}`);
expect(bookSpreadSchema.safeParse(inner(result.doc)).success).toBe(true);
return result.doc;
}
const refusal = (result: ReturnType<typeof applyBookOps>) => (result.ok ? null : result.error.code);
const only = (doc: BookDocument, id = 't1') => inner(doc).elements.find((e) => e.id === id) as BookTextElement;
describe('style sets the effects of a text', () => {
it('BKTOP-001: sets each effect, and null takes one off again', () => {
const doc = ok(
run(book([text('t1')]), [
{
op: 'style',
elementId: 't1',
set: {
kind: 'text',
transform: 'upper',
vAlign: 'bottom',
outline: { color: '#ffffff', width: 0.4 },
shadow: { dx: 1, dy: 1.5, color: '#111111' },
highlight: { color: '#111111', padX: 2 },
curve: { type: 'arc', bend: 30 },
},
},
]),
);
expect(only(doc)).toMatchObject({
transform: 'upper',
vAlign: 'bottom',
outline: { color: '#ffffff', width: 0.4 },
shadow: { dx: 1, dy: 1.5, color: '#111111', alpha: 1 },
highlight: { color: '#111111', padX: 2, padY: 0.3 },
curve: { type: 'arc', bend: 30, start: 0 },
});
const off = ok(run(doc, [{ op: 'style', elementId: 't1', set: { kind: 'text', outline: null, curve: null } }]));
expect(only(off).outline).toBeNull();
expect(only(off).curve).toBeNull();
expect(only(off).shadow).toMatchObject({ dx: 1 });
});
it('BKTOP-002: fills the letters only with a photo of the gallery', () => {
const fill = { type: 'photo' as const, photoId: 1, zoom: 1.5 };
const doc = ok(run(book([text('t1')]), [{ op: 'style', elementId: 't1', set: { kind: 'text', fillPaint: fill } }]));
expect(only(doc).fillPaint).toMatchObject({ type: 'photo', photoId: 1, zoom: 1.5, focalX: 0.5 });
const stranger = { type: 'photo' as const, photoId: 99 };
expect(
refusal(run(book([text('t1')]), [{ op: 'style', elementId: 't1', set: { kind: 'text', fillPaint: stranger } }])),
).toBe('photo_not_in_gallery');
expect(refusal(run(book([]), [{ op: 'add', element: { kind: 'text', text: 'Porto', fillPaint: stranger } }]))).toBe(
'photo_not_in_gallery',
);
});
it('BKTOP-003: refuses an effect value the document cannot hold', () => {
const bad = run(book([text('t1')]), [
{ op: 'style', elementId: 't1', set: { kind: 'text', outline: { color: '#ffffff', width: 9 } } },
]);
expect(refusal(bad)).toBe('invalid_op');
});
it('BKTOP-004: adds a text with its effects in one operation', () => {
const doc = ok(
run(book([]), [
{
op: 'add',
element: {
kind: 'text',
preset: 'heading',
text: 'Sintra',
transform: 'smallcaps',
shadow: { dx: 1, dy: 1, color: '#c2410c' },
},
},
]),
);
const el = inner(doc).elements.at(-1) as BookTextElement;
expect(el).toMatchObject({ text: 'Sintra', size: 30, transform: 'smallcaps', shadow: { color: '#c2410c' } });
});
});
describe('a text set to fit its frame', () => {
const long = 'Two weeks along the Portuguese coast';
it('BKTOP-005: is solved after the operations, keeping the size asked for as its ceiling', () => {
const doc = ok(
run(book([text('t1', { text: long, size: 60 })]), [
{ op: 'style', elementId: 't1', set: { kind: 'text', fit: 'shrink' } },
]),
);
expect(only(doc).size).toBeLessThan(60);
expect(only(doc).fitMax).toBe(60);
expect(describeSpread(doc, 'sp-in')?.findings.some((f) => f.code === 'text_overflow')).toBe(false);
});
it('BKTOP-006: takes a new size as the new ceiling', () => {
const fitted = ok(
run(book([text('t1', { text: long, size: 60 })]), [
{ op: 'style', elementId: 't1', set: { kind: 'text', fit: 'shrink' } },
]),
);
const doc = ok(run(fitted, [{ op: 'style', elementId: 't1', set: { kind: 'text', size: 12 } }]));
expect(only(doc)).toMatchObject({ size: 12, fitMax: 12 });
});
});
describe('add_lockup', () => {
it('BKTOP-007: places a lockup on the right-hand page, its words by role and every text fitted', () => {
const doc = ok(
run(book([]), [
{ op: 'add_lockup', lockup: 'poster-stack', words: { title: 'Lisbon', subtitle: 'Summer', year: '2026' } },
]),
);
const els = inner(doc).elements;
expect(els.map((e) => (e.kind === 'text' ? e.text : e.kind))).toEqual(['Lisbon', 'Summer', '2026']);
for (const e of els) expect(e.frame.x).toBeGreaterThan(210);
expect(els.every((e) => e.kind === 'text' && e.fit === 'shrink')).toBe(true);
// One group, so the set moves as one.
expect(els[0]?.group).toBeTruthy();
expect(new Set(els.map((e) => e.group)).size).toBe(1);
});
it('BKTOP-008: sets the lockup into a box, in the colours given, a photo word filled from the gallery', () => {
const doc = ok(
run(book([]), [
{
op: 'add_lockup',
lockup: 'photo-word',
words: { title: 'Porto', kicker: 'Travel journal' },
box: { x: 10, y: 10, w: 150, h: 75 },
ink: '#202020',
accent: '#0f766e',
photoId: 1,
},
]),
);
const [kicker, title] = inner(doc).elements as BookTextElement[];
expect(kicker?.color).toBe('#202020');
expect(title?.fillPaint).toMatchObject({ type: 'photo', photoId: 1 });
expect(title?.frame.x).toBeGreaterThanOrEqual(10);
expect((title?.frame.x ?? 0) + (title?.frame.w ?? 0)).toBeLessThanOrEqual(160.01);
});
it('BKTOP-009: refuses a lockup without words, a photo outside the gallery and a page too full for it', () => {
expect(refusal(run(book([]), [{ op: 'add_lockup', lockup: 'postmark', words: {} }]))).toBe('text_required');
expect(
refusal(run(book([]), [{ op: 'add_lockup', lockup: 'photo-word', words: { title: 'Porto' }, photoId: 42 }])),
).toBe('photo_not_in_gallery');
const full = Array.from({ length: 89 }, (_, i) => text(`f${i}`));
expect(
refusal(
run(book(full), [{ op: 'add_lockup', lockup: 'poster-stack', words: { title: 'Lisbon', year: '2026' } }]),
),
).toBe('too_many_elements');
});
it('BKTOP-010: takes only the lockups and the roles it knows', () => {
expect(bookAddLockupOpSchema.safeParse({ op: 'add_lockup', lockup: 'banner', words: { title: 'A' } }).success).toBe(
false,
);
expect(bookAddLockupOpSchema.safeParse({ op: 'add_lockup', lockup: 'stamp', words: { motto: 'A' } }).success).toBe(
false,
);
expect(
bookAddLockupOpSchema.safeParse({ op: 'add_lockup', lockup: 'stamp', words: { place: 'Faro' } }).success,
).toBe(true);
});
});
describe('the page in words', () => {
it('BKTOP-011: says what effects each text carries, in the terms style sets them by', () => {
const doc = book([
text('t1', {
transform: 'upper',
vAlign: 'middle',
outline: { color: '#ffffff', width: 0.4 },
shadow: { dx: 1, dy: 1, color: '#111111' },
highlight: { color: '#c2410c' },
fit: 'shrink',
fitMax: 30,
}),
text('t2', { curve: { type: 'circle', start: 90 }, fillPaint: { type: 'solid', color: '#111111', alpha: 0 } }),
text('t3', { curve: { type: 'arc', bend: 20 }, fillPaint: { type: 'photo', photoId: 1 } }),
text('t4'),
]);
const words = describeSpread(doc, 'sp-in')?.text ?? '';
expect(words).toContain(
'capitals, set at the middle of its frame, outline #ffffff 0.4 mm, hard shadow #111111 offset 1/1 mm, on a band of #c2410c, shrunk to fit (chosen 30 pt)',
);
expect(words).toContain('hollow letters (outline only), round a circle, centred at 90 deg');
expect(words).toContain('letters filled with photo 1 (focal 0.50/0.50, zoom 1), on an arc, bend 20');
expect(words).toContain('sans 24 pt, left, #1a1a1a: "Lisbon"');
});
it('BKTOP-012: says a translucent fill by its alpha', () => {
const doc = book([text('t1', { fillPaint: { type: 'solid', color: '#111111', alpha: 0.5 } })]);
expect(describeSpread(doc, 'sp-in')?.text).toContain('fill #111111 at alpha 0.5');
});
});
+7 -4
View File
@@ -3,6 +3,7 @@ import { BOOK_LINE_FIELDS } from './book-line.schema';
import { bookPaintFieldSchema } from './book-paint.schema';
import { BOOK_PHOTO_LOOK_FIELDS } from './book-photo-look.schema';
import { BOOK_EDGE_FIELDS, BOOK_SHAPES_MORE } from './book-shapes-more';
import { BOOK_TEXT_EFFECT_FIELDS, MAX_BOOK_TEXT_SIZE } from './book-text-effects.schema';
import { MAX_STICKER_SLOTS } from './stickers/sticker-types';
import * as themed from './theme/book-theme.schema';
@@ -270,15 +271,15 @@ export const bookTextElementSchema = z.object({
kind: z.literal('text'),
text: z.string().max(MAX_TEXT_LENGTH).default(''),
font: bookCatalogueIdSchema(BOOK_FONTS_IDS).default('sans'),
/** Points. */
size: z.number().min(4).max(200).default(11),
/** Points, up to a word across a whole cover. */
size: z.number().min(4).max(MAX_BOOK_TEXT_SIZE).default(11),
weight: bookWeightSchema.default(400),
italic: z.boolean().default(false),
align: z.enum(['left', 'center', 'right', 'justify']).default('left'),
/** Multiple of the font size. */
leading: z.number().min(0.7).max(3).default(1.45),
/** Ems, may be negative. */
tracking: z.number().min(-0.2).max(1).default(0),
/** Ems, may be negative; wide enough for very spaced capitals. */
tracking: z.number().min(-0.2).max(1.5).default(0),
color: hex.default('#1a1a1a'),
...themed.THEMED_TEXT,
/** Where this text came from, when it came from the journey. */
@@ -327,6 +328,8 @@ export const bookTextElementSchema = z.object({
.default(null),
/** True once a human edited the text; stops it re-reading its source. */
overridden: z.boolean().default(false),
/** Outline, shadow, band, curve, fill, case, vertical place and fit (book-text-effects.schema.ts). */
...BOOK_TEXT_EFFECT_FIELDS,
});
export const bookShapeElementSchema = z.object({
+3 -1
View File
@@ -15,6 +15,7 @@ import type {
import { paintWords } from './book-paint';
import { photoLookWords } from './book-photo-look';
import { bookStickerWords } from './book-sticker';
import { bookTextEffectWords } from './book-text-describe';
import {
bookPageSetupSchema,
type BookDocument,
@@ -99,7 +100,8 @@ function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefi
const set = `${el.font} (${bookFontRole(el.font)}) ${n(el.size)} pt${weight === 400 ? '' : ` ${bookWeightWord(weight)}`}`;
const style = `${set}${el.italic ? ' italic' : ''}, ${el.align}, ${el.color}`;
const bound = el.binding ? `, bound to ${el.binding.source}${el.overridden ? ' (overridden)' : ''}` : '';
return `${style}: "${head(el.text, 80)}"${bound}`;
const effects = bookTextEffectWords(el);
return `${style}${effects ? `, ${effects}` : ''}: "${head(el.text, 80)}"${bound}`;
}
case 'shape':
return shapeWords(el);
+2
View File
@@ -65,5 +65,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'خطوط كثيرة جدًا',
'assistant.card.book.finding.palette_drift': 'ألوان غير متناسقة',
'assistant.card.book.finding.repeat_layout': 'التخطيط نفسه مرارًا',
'assistant.card.book.finding.curve_overflow': 'النص المنحني يتجاوز قوسه',
'assistant.card.book.finding.effect_heavy': 'حدّ سميك أكثر من اللازم',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'حالة الأحرف والموضع',
'journey.studio.fx.case': 'حالة الأحرف',
'journey.studio.fx.caseNone': 'كما كُتبت',
'journey.studio.fx.caseUpper': 'أحرف كبيرة',
'journey.studio.fx.caseLower': 'أحرف صغيرة',
'journey.studio.fx.caseSmall': 'أحرف كبيرة مصغّرة',
'journey.studio.fx.vertical': 'داخل الإطار',
'journey.studio.fx.top': 'أعلى',
'journey.studio.fx.middle': 'وسط',
'journey.studio.fx.bottom': 'أسفل',
'journey.studio.fx.fit': 'تصغير ليناسب الإطار',
'journey.studio.fx.fitHint':
'الحجم الذي تحدده هو أكبر حجم للنص. يصغر النص عندما يضيق الإطار ويكبر مجددًا عندما تتوفر مساحة.',
'journey.studio.fx.effects': 'تأثيرات',
'journey.studio.fx.shadow': 'ظل حاد',
'journey.studio.fx.across': 'أفقيًا',
'journey.studio.fx.down': 'عموديًا',
'journey.studio.fx.band': 'شريط تظليل',
'journey.studio.fx.padAcross': 'مساحة على الجانبين',
'journey.studio.fx.padDown': 'مساحة أعلى وأسفل',
'journey.studio.fx.bandDrawn': 'يُرسم النص المنحني والنص المملوء بصورة دون شريط تظليل.',
'journey.studio.fx.curve': 'انحناء',
'journey.studio.fx.straight': 'مستقيم',
'journey.studio.fx.arc': 'قوس',
'journey.studio.fx.circle': 'دائرة',
'journey.studio.fx.bend': 'مقدار الانحناء',
'journey.studio.fx.start': 'المركز عند',
'journey.studio.fx.letters': 'الأحرف',
'journey.studio.fx.hollow': 'مفرغ',
'journey.studio.fx.noPhotos': 'لا توجد في هذه الرحلة صور بعد لملء الأحرف بها.',
'journey.studio.fx.pickPhoto': 'صورة داخل الأحرف',
'journey.studio.fx.focalHint': 'اسحب لاختيار الجزء من الصورة الذي يظهر عبر الأحرف.',
'journey.studio.fx.zoom': 'تكبير',
'journey.studio.fx.drawnHint':
'لا يلتف النص المنحني أو المملوء بصورة: يحمل القوس سطرًا واحدًا، ويبدأ الملء بالصورة سطرًا جديدًا حيث تضغط Enter.',
'journey.studio.textStylesHint':
'يُنضَّد كل نمط بعنوان كتابك ويتكيف معه. يصل محددًا كمجموعة واحدة لتنقله دفعة واحدة؛ انقر جزءًا منفردًا لتغيير كلماته.',
'journey.studio.lockupGroup.titles': 'العناوين',
'journey.studio.lockupGroup.badges': 'الشارات والأختام',
'journey.studio.lockupGroup.chapters': 'الفصول',
'journey.studio.lockupGroup.quotes': 'الاقتباسات والتعليقات والأماكن',
'journey.studio.lockup.poster-stack': 'ملصق مكدّس',
'journey.studio.lockup.spaced-date': 'سطر التاريخ والعنوان',
'journey.studio.lockup.serif-sun': 'عنوان مذيّل مع شمس',
'journey.studio.lockup.title-script-year': 'عنوان مع سطر مائل',
'journey.studio.lockup.script-overlay': 'كلمة فوق العنوان',
'journey.studio.lockup.photo-word': 'صورة في الأحرف',
'journey.studio.lockup.fabric-band': 'عنوان على شريط',
'journey.studio.lockup.arched-title': 'عنوان مقوّس',
'journey.studio.lockup.retro-shadow': 'ظل عتيق',
'journey.studio.lockup.outline-echo': 'صدى الحواف',
'journey.studio.lockup.minimal-caps': 'أحرف كبيرة متباعدة',
'journey.studio.lockup.circle-badge': 'شارة دائرية',
'journey.studio.lockup.postmark': 'ختم بريدي',
'journey.studio.lockup.stamp': 'ختم',
'journey.studio.lockup.ticket': 'تذكرة',
'journey.studio.lockup.chapter-outline': 'رقم مفرغ',
'journey.studio.lockup.chapter-rules': 'رقم بين خطين',
'journey.studio.lockup.day-opener': 'افتتاحية اليوم',
'journey.studio.lockup.quote-classic': 'اقتباس كلاسيكي',
'journey.studio.lockup.quote-band': 'اقتباس على أشرطة',
'journey.studio.lockup.caption-rule': 'تعليق مع خط',
'journey.studio.lockup.caption-number': 'تعليق مرقّم',
'journey.studio.lockup.coords-pin': 'إحداثيات مع دبوس',
'journey.studio.lockup.coords-stack': 'المكان والإحداثيات',
'journey.studio.lockupWord.title': 'لشبونة',
'journey.studio.lockupWord.subtitle': 'صيف على البحر',
'journey.studio.lockupWord.kicker': 'مذكرات سفر',
'journey.studio.lockupWord.overlay': 'سفر',
'journey.studio.lockupWord.place': 'البرتغال',
'journey.studio.lockupWord.dates': 'من 12 إلى 26 أغسطس',
'journey.studio.lockupWord.quote': 'أجمل المناظر تأتي بعد أصعب الصعودات.',
'journey.studio.lockupWord.caption': 'ألفاما في أول الضوء',
'journey.studio.lockupWord.label': 'اليوم',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Həddən çox şrift',
'assistant.card.book.finding.palette_drift': 'Uyğun gəlməyən rənglər',
'assistant.card.book.finding.repeat_layout': 'Təkrar-təkrar eyni düzən',
'assistant.card.book.finding.curve_overflow': 'Əyilmiş mətn öz qövsündən kənara çıxır',
'assistant.card.book.finding.effect_heavy': 'Həddən artıq qalın kontur',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Hərf ölçüsü və yer',
'journey.studio.fx.case': 'Hərf ölçüsü',
'journey.studio.fx.caseNone': 'Yazıldığı kimi',
'journey.studio.fx.caseUpper': 'Böyük hərflər',
'journey.studio.fx.caseLower': 'Kiçik hərflər',
'journey.studio.fx.caseSmall': 'Kiçik baş hərflər',
'journey.studio.fx.vertical': 'Çərçivədə',
'journey.studio.fx.top': 'Yuxarı',
'journey.studio.fx.middle': 'Orta',
'journey.studio.fx.bottom': 'Aşağı',
'journey.studio.fx.fit': 'Çərçivəyə sığdır',
'journey.studio.fx.fitHint':
'Təyin etdiyiniz ölçü ən böyük ölçüdür. Çərçivə kiçiləndə mətn kiçilir, yer olanda yenidən böyüyür.',
'journey.studio.fx.effects': 'Effektlər',
'journey.studio.fx.shadow': 'Kəskin kölgə',
'journey.studio.fx.across': 'Üfüqi',
'journey.studio.fx.down': 'Şaquli',
'journey.studio.fx.band': 'Marker zolağı',
'journey.studio.fx.padAcross': 'Yanlarda boşluq',
'journey.studio.fx.padDown': 'Yuxarıda və aşağıda boşluq',
'journey.studio.fx.bandDrawn': 'Əyilmiş və fotoşəkillə doldurulmuş mətn marker zolağı olmadan çəkilir.',
'journey.studio.fx.curve': 'Əyilmə',
'journey.studio.fx.straight': 'Düz',
'journey.studio.fx.arc': 'Qövs',
'journey.studio.fx.circle': 'Dairə',
'journey.studio.fx.bend': 'Əyrilik',
'journey.studio.fx.start': 'Mərkəz',
'journey.studio.fx.letters': 'Hərflər',
'journey.studio.fx.hollow': 'İçiboş',
'journey.studio.fx.noPhotos': 'Bu səyahətdə hərfləri doldurmaq üçün hələ fotoşəkil yoxdur.',
'journey.studio.fx.pickPhoto': 'Hərflərin içində fotoşəkil',
'journey.studio.fx.focalHint': 'Fotoşəklin hansı hissəsinin hərflərdən görünəcəyini seçmək üçün sürüşdürün.',
'journey.studio.fx.zoom': 'Böyütmə',
'journey.studio.fx.drawnHint':
'Əyilmiş və fotoşəkillə doldurulmuş mətn sətirdən keçmir: qövs bir sətir daşıyır, fotoşəkil doldurması Enter basdığınız yerdə yeni sətrə keçir.',
'journey.studio.textStylesHint':
'Hər üslub kitabınızın başlığı ilə qurulur və ona uyğunlaşır. Bir dəst kimi seçilmiş gəlir ki, onu bir dəfəyə köçürəsiniz; sözlərini dəyişmək üçün tək bir hissəyə klikləyin.',
'journey.studio.lockupGroup.titles': 'Başlıqlar',
'journey.studio.lockupGroup.badges': 'Nişanlar və möhürlər',
'journey.studio.lockupGroup.chapters': 'Fəsillər',
'journey.studio.lockupGroup.quotes': 'Sitatlar, izahlar və yerlər',
'journey.studio.lockup.poster-stack': 'Afişa yığını',
'journey.studio.lockup.spaced-date': 'Tarix sətri və başlıq',
'journey.studio.lockup.serif-sun': 'Günəşli serifli başlıq',
'journey.studio.lockup.title-script-year': 'Kursiv sətirli başlıq',
'journey.studio.lockup.script-overlay': 'Başlığın üstündə söz',
'journey.studio.lockup.photo-word': 'Hərflərdə fotoşəkil',
'journey.studio.lockup.fabric-band': 'Lent üzərində başlıq',
'journey.studio.lockup.arched-title': 'Tağlı başlıq',
'journey.studio.lockup.retro-shadow': 'Retro kölgə',
'journey.studio.lockup.outline-echo': 'Kontur əks-sədası',
'journey.studio.lockup.minimal-caps': 'Seyrək böyük hərflər',
'journey.studio.lockup.circle-badge': 'Dəyirmi nişan',
'journey.studio.lockup.postmark': 'Poçt möhürü',
'journey.studio.lockup.stamp': 'Möhür',
'journey.studio.lockup.ticket': 'Bilet',
'journey.studio.lockup.chapter-outline': 'Konturlu rəqəm',
'journey.studio.lockup.chapter-rules': 'Xətlər arasında rəqəm',
'journey.studio.lockup.day-opener': 'Günün açılışı',
'journey.studio.lockup.quote-classic': 'Klassik sitat',
'journey.studio.lockup.quote-band': 'Zolaqlarda sitat',
'journey.studio.lockup.caption-rule': 'Xətli izah',
'journey.studio.lockup.caption-number': 'Nömrəli izah',
'journey.studio.lockup.coords-pin': 'Sancaqlı koordinatlar',
'journey.studio.lockup.coords-stack': 'Yer və koordinatlar',
'journey.studio.lockupWord.title': 'Lissabon',
'journey.studio.lockupWord.subtitle': 'Dəniz kənarında bir yay',
'journey.studio.lockupWord.kicker': 'Səyahət gündəliyi',
'journey.studio.lockupWord.overlay': 'səyahət',
'journey.studio.lockupWord.place': 'Portuqaliya',
'journey.studio.lockupWord.dates': '12 avqustdan 26 avqusta',
'journey.studio.lockupWord.quote': 'Ən gözəl mənzərələr ən çətin yoxuşlardan sonra gəlir.',
'journey.studio.lockupWord.caption': 'İlk işıqda Alfama',
'journey.studio.lockupWord.label': 'Gün',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Fontes demais',
'assistant.card.book.finding.palette_drift': 'Cores que não combinam',
'assistant.card.book.finding.repeat_layout': 'O mesmo layout de novo e de novo',
'assistant.card.book.finding.curve_overflow': 'Texto curvo passa do fim da curva',
'assistant.card.book.finding.effect_heavy': 'Um contorno grosso demais',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Maiúsculas e posição',
'journey.studio.fx.case': 'Maiúsculas',
'journey.studio.fx.caseNone': 'Como digitado',
'journey.studio.fx.caseUpper': 'Maiúsculas',
'journey.studio.fx.caseLower': 'Minúsculas',
'journey.studio.fx.caseSmall': 'Versaletes',
'journey.studio.fx.vertical': 'No quadro',
'journey.studio.fx.top': 'Em cima',
'journey.studio.fx.middle': 'No meio',
'journey.studio.fx.bottom': 'Embaixo',
'journey.studio.fx.fit': 'Ajustar ao quadro',
'journey.studio.fx.fitHint':
'O tamanho escolhido é o maior usado. O texto diminui quando o quadro fica pequeno demais e volta a crescer quando há espaço.',
'journey.studio.fx.effects': 'Efeitos',
'journey.studio.fx.shadow': 'Sombra dura',
'journey.studio.fx.across': 'Na horizontal',
'journey.studio.fx.down': 'Na vertical',
'journey.studio.fx.band': 'Faixa de marca-texto',
'journey.studio.fx.padAcross': 'Espaço nas laterais',
'journey.studio.fx.padDown': 'Espaço em cima e embaixo',
'journey.studio.fx.bandDrawn': 'Texto curvo ou preenchido com foto é desenhado sem faixa de marca-texto.',
'journey.studio.fx.curve': 'Curva',
'journey.studio.fx.straight': 'Reto',
'journey.studio.fx.arc': 'Arco',
'journey.studio.fx.circle': 'Círculo',
'journey.studio.fx.bend': 'Curvatura',
'journey.studio.fx.start': 'Centralizado em',
'journey.studio.fx.letters': 'Letras',
'journey.studio.fx.hollow': 'Vazado',
'journey.studio.fx.noPhotos': 'Esta viagem ainda não tem fotos para preencher as letras.',
'journey.studio.fx.pickPhoto': 'Foto dentro das letras',
'journey.studio.fx.focalHint': 'Arraste para escolher qual parte da foto aparece nas letras.',
'journey.studio.fx.zoom': 'Zoom', // same-as-en
'journey.studio.fx.drawnHint':
'Texto curvo ou preenchido com foto não quebra linha: uma curva leva uma linha só e um preenchimento com foto começa uma linha nova onde você aperta Enter.',
'journey.studio.textStylesHint':
'Cada estilo é composto com o título do seu livro e se ajusta a ele. Ele chega selecionado como um conjunto para você mover de uma vez; clique em uma parte para trocar as palavras.',
'journey.studio.lockupGroup.titles': 'Títulos',
'journey.studio.lockupGroup.badges': 'Selos e carimbos',
'journey.studio.lockupGroup.chapters': 'Capítulos',
'journey.studio.lockupGroup.quotes': 'Citações, legendas e lugares',
'journey.studio.lockup.poster-stack': 'Cartaz empilhado',
'journey.studio.lockup.spaced-date': 'Linha de data e título',
'journey.studio.lockup.serif-sun': 'Título serifado com sol',
'journey.studio.lockup.title-script-year': 'Título com linha em itálico',
'journey.studio.lockup.script-overlay': 'Palavra sobre o título',
'journey.studio.lockup.photo-word': 'Foto nas letras',
'journey.studio.lockup.fabric-band': 'Título numa faixa',
'journey.studio.lockup.arched-title': 'Título em arco',
'journey.studio.lockup.retro-shadow': 'Sombra retrô',
'journey.studio.lockup.outline-echo': 'Eco de contornos',
'journey.studio.lockup.minimal-caps': 'Maiúsculas espaçadas',
'journey.studio.lockup.circle-badge': 'Selo redondo',
'journey.studio.lockup.postmark': 'Carimbo postal',
'journey.studio.lockup.stamp': 'Carimbo',
'journey.studio.lockup.ticket': 'Bilhete',
'journey.studio.lockup.chapter-outline': 'Número contornado',
'journey.studio.lockup.chapter-rules': 'Número entre fios',
'journey.studio.lockup.day-opener': 'Abertura do dia',
'journey.studio.lockup.quote-classic': 'Citação clássica',
'journey.studio.lockup.quote-band': 'Citação em faixas',
'journey.studio.lockup.caption-rule': 'Legenda com fio',
'journey.studio.lockup.caption-number': 'Legenda numerada',
'journey.studio.lockup.coords-pin': 'Coordenadas com alfinete',
'journey.studio.lockup.coords-stack': 'Lugar e coordenadas',
'journey.studio.lockupWord.title': 'Lisboa',
'journey.studio.lockupWord.subtitle': 'Um verão à beira-mar',
'journey.studio.lockupWord.kicker': 'Diário de viagem',
'journey.studio.lockupWord.overlay': 'viagem',
'journey.studio.lockupWord.place': 'Portugal', // same-as-en
'journey.studio.lockupWord.dates': 'De 12 a 26 de agosto',
'journey.studio.lockupWord.quote': 'As vistas mais bonitas vêm depois das subidas mais difíceis.',
'journey.studio.lockupWord.caption': 'Alfama na primeira luz',
'journey.studio.lockupWord.label': 'Dia',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Massa tipografies',
'assistant.card.book.finding.palette_drift': 'Colors que no combinen',
'assistant.card.book.finding.repeat_layout': 'El mateix disseny una vegada i una altra',
'assistant.card.book.finding.curve_overflow': 'El text corbat surt de la seva corba',
'assistant.card.book.finding.effect_heavy': 'Un contorn massa gruixut',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -128,6 +130,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Majúscules i posició',
'journey.studio.fx.case': 'Majúscules',
'journey.studio.fx.caseNone': "Tal com s'ha escrit",
'journey.studio.fx.caseUpper': 'Majúscules',
'journey.studio.fx.caseLower': 'Minúscules',
'journey.studio.fx.caseSmall': 'Versaletes',
'journey.studio.fx.vertical': 'Dins el marc',
'journey.studio.fx.top': 'A dalt',
'journey.studio.fx.middle': 'Al mig',
'journey.studio.fx.bottom': 'A baix',
'journey.studio.fx.fit': 'Ajusta al marc',
'journey.studio.fx.fitHint':
"La mida que tries és la més gran. El text s'encongeix quan el marc es queda petit i torna a créixer quan hi ha espai.",
'journey.studio.fx.effects': 'Efectes',
'journey.studio.fx.shadow': 'Ombra dura',
'journey.studio.fx.across': 'En horitzontal',
'journey.studio.fx.down': 'En vertical',
'journey.studio.fx.band': 'Banda de subratllador',
'journey.studio.fx.padAcross': 'Espai als costats',
'journey.studio.fx.padDown': 'Espai a dalt i a baix',
'journey.studio.fx.bandDrawn': 'El text corbat o farcit amb una foto es dibuixa sense banda de subratllador.',
'journey.studio.fx.curve': 'Corba',
'journey.studio.fx.straight': 'Recte',
'journey.studio.fx.arc': 'Arc', // same-as-en
'journey.studio.fx.circle': 'Cercle',
'journey.studio.fx.bend': 'Curvatura',
'journey.studio.fx.start': 'Centrat a',
'journey.studio.fx.letters': 'Lletres',
'journey.studio.fx.hollow': 'Buit',
'journey.studio.fx.noPhotos': 'Aquest viatge encara no té fotos per omplir les lletres.',
'journey.studio.fx.pickPhoto': 'Foto dins les lletres',
'journey.studio.fx.focalHint': 'Arrossega per triar quina part de la foto es veu a través de les lletres.',
'journey.studio.fx.zoom': 'Zoom', // same-as-en
'journey.studio.fx.drawnHint':
'El text corbat o farcit amb una foto no salta de línia: una corba porta una sola línia i un farciment de foto comença una línia nova on prems Retorn.',
'journey.studio.textStylesHint':
"Cada estil es compon amb el títol del teu llibre i s'hi ajusta. Arriba seleccionat com un conjunt per moure'l d'una vegada; fes clic en una part per canviar-ne les paraules.",
'journey.studio.lockupGroup.titles': 'Títols',
'journey.studio.lockupGroup.badges': 'Insígnies i segells',
'journey.studio.lockupGroup.chapters': 'Capítols',
'journey.studio.lockupGroup.quotes': 'Cites, peus de foto i llocs',
'journey.studio.lockup.poster-stack': 'Cartell apilat',
'journey.studio.lockup.spaced-date': 'Línia de data i títol',
'journey.studio.lockup.serif-sun': 'Títol amb serifa i sol',
'journey.studio.lockup.title-script-year': 'Títol amb línia en cursiva',
'journey.studio.lockup.script-overlay': 'Paraula sobre el títol',
'journey.studio.lockup.photo-word': 'Foto a les lletres',
'journey.studio.lockup.fabric-band': 'Títol sobre una banda',
'journey.studio.lockup.arched-title': 'Títol en arc',
'journey.studio.lockup.retro-shadow': 'Ombra retro',
'journey.studio.lockup.outline-echo': 'Eco de contorns',
'journey.studio.lockup.minimal-caps': 'Majúscules espaiades',
'journey.studio.lockup.circle-badge': 'Insígnia rodona',
'journey.studio.lockup.postmark': 'Mata-segells',
'journey.studio.lockup.stamp': 'Segell',
'journey.studio.lockup.ticket': 'Bitllet',
'journey.studio.lockup.chapter-outline': 'Número perfilat',
'journey.studio.lockup.chapter-rules': 'Número entre filets',
'journey.studio.lockup.day-opener': 'Obertura del dia',
'journey.studio.lockup.quote-classic': 'Cita clàssica',
'journey.studio.lockup.quote-band': 'Cita sobre bandes',
'journey.studio.lockup.caption-rule': 'Peu de foto amb filet',
'journey.studio.lockup.caption-number': 'Peu de foto numerat',
'journey.studio.lockup.coords-pin': 'Coordenades amb xinxeta',
'journey.studio.lockup.coords-stack': 'Lloc i coordenades',
'journey.studio.lockupWord.title': 'Lisboa',
'journey.studio.lockupWord.subtitle': 'Un estiu vora el mar',
'journey.studio.lockupWord.kicker': 'Diari de viatge',
'journey.studio.lockupWord.overlay': 'viatge',
'journey.studio.lockupWord.place': 'Portugal', // same-as-en
'journey.studio.lockupWord.dates': "Del 12 al 26 d'agost",
'journey.studio.lockupWord.quote': 'Les millors vistes arriben després de les pujades més dures.',
'journey.studio.lockupWord.caption': "L'Alfama amb la primera llum",
'journey.studio.lockupWord.label': 'Dia',
};
export default studioText;
+2
View File
@@ -53,5 +53,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Příliš mnoho písem',
'assistant.card.book.finding.palette_drift': 'Barvy, které k sobě neladí',
'assistant.card.book.finding.repeat_layout': 'Pořád stejné rozvržení',
'assistant.card.book.finding.curve_overflow': 'Zakřivený text přesahuje svůj oblouk',
'assistant.card.book.finding.effect_heavy': 'Příliš silný obrys',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Velikost písmen a umístění',
'journey.studio.fx.case': 'Velikost písmen',
'journey.studio.fx.caseNone': 'Jak napsáno',
'journey.studio.fx.caseUpper': 'Velká písmena',
'journey.studio.fx.caseLower': 'Malá písmena',
'journey.studio.fx.caseSmall': 'Kapitálky',
'journey.studio.fx.vertical': 'V rámečku',
'journey.studio.fx.top': 'Nahoře',
'journey.studio.fx.middle': 'Uprostřed',
'journey.studio.fx.bottom': 'Dole',
'journey.studio.fx.fit': 'Přizpůsobit rámečku',
'journey.studio.fx.fitHint':
'Nastavená velikost je největší, ve které se text sází. Zmenší se, když je rámeček příliš malý, a znovu naroste, jakmile je místo.',
'journey.studio.fx.effects': 'Efekty',
'journey.studio.fx.shadow': 'Ostrý stín',
'journey.studio.fx.across': 'Vodorovně',
'journey.studio.fx.down': 'Svisle',
'journey.studio.fx.band': 'Zvýrazňovací pruh',
'journey.studio.fx.padAcross': 'Místo po stranách',
'journey.studio.fx.padDown': 'Místo nahoře a dole',
'journey.studio.fx.bandDrawn': 'Zakřivený text a text vyplněný fotkou se kreslí bez zvýrazňovacího pruhu.',
'journey.studio.fx.curve': 'Zakřivení',
'journey.studio.fx.straight': 'Rovně',
'journey.studio.fx.arc': 'Oblouk',
'journey.studio.fx.circle': 'Kruh',
'journey.studio.fx.bend': 'Ohyb',
'journey.studio.fx.start': 'Střed na',
'journey.studio.fx.letters': 'Písmena',
'journey.studio.fx.hollow': 'Duté',
'journey.studio.fx.noPhotos': 'Tato cesta zatím nemá fotky, kterými by šlo vyplnit písmena.',
'journey.studio.fx.pickPhoto': 'Fotka v písmenech',
'journey.studio.fx.focalHint': 'Tažením vyberte, která část fotky prosvítá písmeny.',
'journey.studio.fx.zoom': 'Přiblížení',
'journey.studio.fx.drawnHint':
'Zakřivený text a text vyplněný fotkou se nezalamuje: oblouk nese jeden řádek, výplň fotkou začne nový řádek tam, kde stisknete Enter.',
'journey.studio.textStylesHint':
'Každý styl se vysází s názvem vaší knihy a přizpůsobí se mu. Přijde vybraný jako celek, takže ho přesunete najednou; kliknutím na jednotlivou část změníte její slova.',
'journey.studio.lockupGroup.titles': 'Nadpisy',
'journey.studio.lockupGroup.badges': 'Odznaky a razítka',
'journey.studio.lockupGroup.chapters': 'Kapitoly',
'journey.studio.lockupGroup.quotes': 'Citáty, popisky a místa',
'journey.studio.lockup.poster-stack': 'Plakátový sloupec',
'journey.studio.lockup.spaced-date': 'Řádek data a nadpis',
'journey.studio.lockup.serif-sun': 'Patkový nadpis se sluncem',
'journey.studio.lockup.title-script-year': 'Nadpis s řádkem kurzívou',
'journey.studio.lockup.script-overlay': 'Slovo přes nadpis',
'journey.studio.lockup.photo-word': 'Fotka v písmenech',
'journey.studio.lockup.fabric-band': 'Nadpis na pásu',
'journey.studio.lockup.arched-title': 'Klenutý nadpis',
'journey.studio.lockup.retro-shadow': 'Retro stín',
'journey.studio.lockup.outline-echo': 'Ozvěna obrysů',
'journey.studio.lockup.minimal-caps': 'Prostrkané verzálky',
'journey.studio.lockup.circle-badge': 'Kulatý odznak',
'journey.studio.lockup.postmark': 'Poštovní razítko',
'journey.studio.lockup.stamp': 'Razítko',
'journey.studio.lockup.ticket': 'Lístek',
'journey.studio.lockup.chapter-outline': 'Obrysové číslo',
'journey.studio.lockup.chapter-rules': 'Číslo mezi linkami',
'journey.studio.lockup.day-opener': 'Úvod dne',
'journey.studio.lockup.quote-classic': 'Klasický citát',
'journey.studio.lockup.quote-band': 'Citát na pásech',
'journey.studio.lockup.caption-rule': 'Popisek s linkou',
'journey.studio.lockup.caption-number': 'Číslovaný popisek',
'journey.studio.lockup.coords-pin': 'Souřadnice se špendlíkem',
'journey.studio.lockup.coords-stack': 'Místo a souřadnice',
'journey.studio.lockupWord.title': 'Lisabon',
'journey.studio.lockupWord.subtitle': 'Léto u moře',
'journey.studio.lockupWord.kicker': 'Cestovní deník',
'journey.studio.lockupWord.overlay': 'cesta',
'journey.studio.lockupWord.place': 'Portugalsko',
'journey.studio.lockupWord.dates': '12. až 26. srpna',
'journey.studio.lockupWord.quote': 'Nejkrásnější výhledy přicházejí po nejtěžších výstupech.',
'journey.studio.lockupWord.caption': 'Alfama za prvního světla',
'journey.studio.lockupWord.label': 'Den',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Zu viele Schriften',
'assistant.card.book.finding.palette_drift': 'Farben, die nicht zusammenpassen',
'assistant.card.book.finding.repeat_layout': 'Immer wieder dasselbe Layout',
'assistant.card.book.finding.curve_overflow': 'Gebogener Text läuft über seinen Bogen hinaus',
'assistant.card.book.finding.effect_heavy': 'Eine zu kräftige Kontur',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Schreibweise und Position',
'journey.studio.fx.case': 'Schreibweise',
'journey.studio.fx.caseNone': 'Wie getippt',
'journey.studio.fx.caseUpper': 'Großbuchstaben',
'journey.studio.fx.caseLower': 'Kleinbuchstaben',
'journey.studio.fx.caseSmall': 'Kapitälchen',
'journey.studio.fx.vertical': 'Im Rahmen',
'journey.studio.fx.top': 'Oben',
'journey.studio.fx.middle': 'Mitte',
'journey.studio.fx.bottom': 'Unten',
'journey.studio.fx.fit': 'An Rahmen anpassen',
'journey.studio.fx.fitHint':
'Die eingestellte Größe ist die größte, in der der Text gesetzt wird. Er wird kleiner, wenn der Rahmen zu klein wird, und wächst wieder, sobald Platz ist.',
'journey.studio.fx.effects': 'Effekte',
'journey.studio.fx.shadow': 'Harter Schatten',
'journey.studio.fx.across': 'Seitlich',
'journey.studio.fx.down': 'Nach unten',
'journey.studio.fx.band': 'Markerband',
'journey.studio.fx.padAcross': 'Platz an den Seiten',
'journey.studio.fx.padDown': 'Platz oben und unten',
'journey.studio.fx.bandDrawn': 'Gebogener und mit Foto gefüllter Text wird ohne Markerband gezeichnet.',
'journey.studio.fx.curve': 'Biegung',
'journey.studio.fx.straight': 'Gerade',
'journey.studio.fx.arc': 'Bogen',
'journey.studio.fx.circle': 'Kreis',
'journey.studio.fx.bend': 'Krümmung',
'journey.studio.fx.start': 'Mitte bei',
'journey.studio.fx.letters': 'Buchstaben',
'journey.studio.fx.hollow': 'Hohl',
'journey.studio.fx.noPhotos': 'Diese Reise hat noch keine Fotos, mit denen sich die Buchstaben füllen lassen.',
'journey.studio.fx.pickPhoto': 'Foto in den Buchstaben',
'journey.studio.fx.focalHint': 'Ziehen, um zu wählen, welcher Teil des Fotos durch die Buchstaben scheint.',
'journey.studio.fx.zoom': 'Zoom', // same-as-en
'journey.studio.fx.drawnHint':
'Gebogener und mit Foto gefüllter Text bricht nicht um: Ein Bogen setzt eine Zeile, eine Fotofüllung beginnt eine neue Zeile, wo du die Eingabetaste drückst.',
'journey.studio.textStylesHint':
'Jeder Stil wird mit dem Titel deines Buchs gesetzt und passt sich ihm an. Er kommt als ein Satz ausgewählt an, damit du ihn auf einmal verschieben kannst; klicke auf ein einzelnes Teil, um seine Worte zu ändern.',
'journey.studio.lockupGroup.titles': 'Titel',
'journey.studio.lockupGroup.badges': 'Abzeichen und Stempel',
'journey.studio.lockupGroup.chapters': 'Kapitel',
'journey.studio.lockupGroup.quotes': 'Zitate, Bildunterschriften und Orte',
'journey.studio.lockup.poster-stack': 'Plakatstapel',
'journey.studio.lockup.spaced-date': 'Datumszeile und Titel',
'journey.studio.lockup.serif-sun': 'Serifentitel mit Sonne',
'journey.studio.lockup.title-script-year': 'Titel mit kursiver Zeile',
'journey.studio.lockup.script-overlay': 'Wort über dem Titel',
'journey.studio.lockup.photo-word': 'Foto in den Buchstaben',
'journey.studio.lockup.fabric-band': 'Titel auf einem Band',
'journey.studio.lockup.arched-title': 'Gewölbter Titel',
'journey.studio.lockup.retro-shadow': 'Retroschatten',
'journey.studio.lockup.outline-echo': 'Konturecho',
'journey.studio.lockup.minimal-caps': 'Gesperrte Versalien',
'journey.studio.lockup.circle-badge': 'Runde Plakette',
'journey.studio.lockup.postmark': 'Poststempel',
'journey.studio.lockup.stamp': 'Stempel',
'journey.studio.lockup.ticket': 'Ticket', // same-as-en
'journey.studio.lockup.chapter-outline': 'Konturierte Zahl',
'journey.studio.lockup.chapter-rules': 'Zahl zwischen Linien',
'journey.studio.lockup.day-opener': 'Tagesauftakt',
'journey.studio.lockup.quote-classic': 'Klassisches Zitat',
'journey.studio.lockup.quote-band': 'Zitat auf Bändern',
'journey.studio.lockup.caption-rule': 'Bildunterschrift mit Linie',
'journey.studio.lockup.caption-number': 'Nummerierte Bildunterschrift',
'journey.studio.lockup.coords-pin': 'Koordinaten mit Pin',
'journey.studio.lockup.coords-stack': 'Ort und Koordinaten',
'journey.studio.lockupWord.title': 'Lissabon',
'journey.studio.lockupWord.subtitle': 'Ein Sommer am Meer',
'journey.studio.lockupWord.kicker': 'Reisetagebuch',
'journey.studio.lockupWord.overlay': 'Reise',
'journey.studio.lockupWord.place': 'Portugal', // same-as-en
'journey.studio.lockupWord.dates': '12. bis 26. August',
'journey.studio.lockupWord.quote': 'Die schönsten Aussichten kommen nach den härtesten Anstiegen.',
'journey.studio.lockupWord.caption': 'Alfama im ersten Licht',
'journey.studio.lockupWord.label': 'Tag',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Too many typefaces',
'assistant.card.book.finding.palette_drift': 'Colours that do not match',
'assistant.card.book.finding.repeat_layout': 'The same layout again and again',
'assistant.card.book.finding.curve_overflow': 'Curved text runs past its curve',
'assistant.card.book.finding.effect_heavy': 'An outline that is too heavy',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Case and placement',
'journey.studio.fx.case': 'Case',
'journey.studio.fx.caseNone': 'As typed',
'journey.studio.fx.caseUpper': 'Capitals',
'journey.studio.fx.caseLower': 'Lower case',
'journey.studio.fx.caseSmall': 'Small caps',
'journey.studio.fx.vertical': 'In the frame',
'journey.studio.fx.top': 'Top',
'journey.studio.fx.middle': 'Middle',
'journey.studio.fx.bottom': 'Bottom',
'journey.studio.fx.fit': 'Shrink to fit',
'journey.studio.fx.fitHint':
'The size you set is the largest the words are set at. They shrink when the frame gets too small and grow back when there is room.',
'journey.studio.fx.effects': 'Effects',
'journey.studio.fx.shadow': 'Hard shadow',
'journey.studio.fx.across': 'Across',
'journey.studio.fx.down': 'Down',
'journey.studio.fx.band': 'Marker band',
'journey.studio.fx.padAcross': 'Room at the sides',
'journey.studio.fx.padDown': 'Room above and below',
'journey.studio.fx.bandDrawn': 'Curved and photo-filled words are drawn without a marker band.',
'journey.studio.fx.curve': 'Curve',
'journey.studio.fx.straight': 'Straight',
'journey.studio.fx.arc': 'Arc',
'journey.studio.fx.circle': 'Circle',
'journey.studio.fx.bend': 'Bend',
'journey.studio.fx.start': 'Centred at',
'journey.studio.fx.letters': 'Letters',
'journey.studio.fx.hollow': 'Hollow',
'journey.studio.fx.noPhotos': 'This journey has no photos to fill the letters with yet.',
'journey.studio.fx.pickPhoto': 'Photo inside the letters',
'journey.studio.fx.focalHint': 'Drag to choose which part of the photo shows through the letters.',
'journey.studio.fx.zoom': 'Zoom',
'journey.studio.fx.drawnHint':
'Curved and photo-filled words do not wrap: a curve sets one line, a photo fill starts a new line where you press Enter.',
'journey.studio.textStylesHint':
"Each style is set in your book's title and sized to fit it. It arrives selected as one set, so you can move it at once; click a single part to change its words.",
'journey.studio.lockupGroup.titles': 'Titles',
'journey.studio.lockupGroup.badges': 'Badges and stamps',
'journey.studio.lockupGroup.chapters': 'Chapters',
'journey.studio.lockupGroup.quotes': 'Quotes, captions and places',
'journey.studio.lockup.poster-stack': 'Poster stack',
'journey.studio.lockup.spaced-date': 'Date line and title',
'journey.studio.lockup.serif-sun': 'Serif title with sun',
'journey.studio.lockup.title-script-year': 'Title with italic line',
'journey.studio.lockup.script-overlay': 'Word over the title',
'journey.studio.lockup.photo-word': 'Photo in the letters',
'journey.studio.lockup.fabric-band': 'Title on a band',
'journey.studio.lockup.arched-title': 'Arched title',
'journey.studio.lockup.retro-shadow': 'Retro shadow',
'journey.studio.lockup.outline-echo': 'Outline echo',
'journey.studio.lockup.minimal-caps': 'Spaced capitals',
'journey.studio.lockup.circle-badge': 'Round badge',
'journey.studio.lockup.postmark': 'Postmark',
'journey.studio.lockup.stamp': 'Stamp',
'journey.studio.lockup.ticket': 'Ticket',
'journey.studio.lockup.chapter-outline': 'Outlined number',
'journey.studio.lockup.chapter-rules': 'Number between rules',
'journey.studio.lockup.day-opener': 'Day opener',
'journey.studio.lockup.quote-classic': 'Classic quote',
'journey.studio.lockup.quote-band': 'Quote on bands',
'journey.studio.lockup.caption-rule': 'Caption with rule',
'journey.studio.lockup.caption-number': 'Numbered caption',
'journey.studio.lockup.coords-pin': 'Coordinates with pin',
'journey.studio.lockup.coords-stack': 'Place and coordinates',
'journey.studio.lockupWord.title': 'Lisbon',
'journey.studio.lockupWord.subtitle': 'A summer by the sea',
'journey.studio.lockupWord.kicker': 'Travel journal',
'journey.studio.lockupWord.overlay': 'travel',
'journey.studio.lockupWord.place': 'Portugal',
'journey.studio.lockupWord.dates': '12 to 26 August',
'journey.studio.lockupWord.quote': 'The best views come after the hardest climbs.',
'journey.studio.lockupWord.caption': 'Alfama at first light',
'journey.studio.lockupWord.label': 'Day',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Demasiadas tipografías',
'assistant.card.book.finding.palette_drift': 'Colores que no combinan',
'assistant.card.book.finding.repeat_layout': 'El mismo diseño una y otra vez',
'assistant.card.book.finding.curve_overflow': 'El texto curvado se sale de su curva',
'assistant.card.book.finding.effect_heavy': 'Un contorno demasiado grueso',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Mayúsculas y posición',
'journey.studio.fx.case': 'Mayúsculas',
'journey.studio.fx.caseNone': 'Como se escribió',
'journey.studio.fx.caseUpper': 'Mayúsculas',
'journey.studio.fx.caseLower': 'Minúsculas',
'journey.studio.fx.caseSmall': 'Versalitas',
'journey.studio.fx.vertical': 'En el marco',
'journey.studio.fx.top': 'Arriba',
'journey.studio.fx.middle': 'Centro',
'journey.studio.fx.bottom': 'Abajo',
'journey.studio.fx.fit': 'Ajustar al marco',
'journey.studio.fx.fitHint':
'El tamaño que eliges es el máximo. El texto se reduce cuando el marco se queda pequeño y vuelve a crecer cuando hay espacio.',
'journey.studio.fx.effects': 'Efectos',
'journey.studio.fx.shadow': 'Sombra dura',
'journey.studio.fx.across': 'Horizontal',
'journey.studio.fx.down': 'Vertical',
'journey.studio.fx.band': 'Banda de resaltado',
'journey.studio.fx.padAcross': 'Espacio a los lados',
'journey.studio.fx.padDown': 'Espacio arriba y abajo',
'journey.studio.fx.bandDrawn': 'El texto curvado o relleno de foto se dibuja sin banda de resaltado.',
'journey.studio.fx.curve': 'Curva',
'journey.studio.fx.straight': 'Recto',
'journey.studio.fx.arc': 'Arco',
'journey.studio.fx.circle': 'Círculo',
'journey.studio.fx.bend': 'Curvatura',
'journey.studio.fx.start': 'Centrado en',
'journey.studio.fx.letters': 'Letras',
'journey.studio.fx.hollow': 'Hueco',
'journey.studio.fx.noPhotos': 'Este viaje aún no tiene fotos para rellenar las letras.',
'journey.studio.fx.pickPhoto': 'Foto dentro de las letras',
'journey.studio.fx.focalHint': 'Arrastra para elegir qué parte de la foto se ve a través de las letras.',
'journey.studio.fx.zoom': 'Zoom', // same-as-en
'journey.studio.fx.drawnHint':
'El texto curvado o relleno de foto no salta de línea: una curva lleva una sola línea y un relleno de foto empieza una línea nueva donde pulses Intro.',
'journey.studio.textStylesHint':
'Cada estilo se compone con el título de tu libro y se ajusta a él. Llega seleccionado como un conjunto para moverlo de una vez; haz clic en una parte para cambiar sus palabras.',
'journey.studio.lockupGroup.titles': 'Títulos',
'journey.studio.lockupGroup.badges': 'Insignias y sellos',
'journey.studio.lockupGroup.chapters': 'Capítulos',
'journey.studio.lockupGroup.quotes': 'Citas, pies de foto y lugares',
'journey.studio.lockup.poster-stack': 'Cartel apilado',
'journey.studio.lockup.spaced-date': 'Línea de fecha y título',
'journey.studio.lockup.serif-sun': 'Título con serifa y sol',
'journey.studio.lockup.title-script-year': 'Título con línea en cursiva',
'journey.studio.lockup.script-overlay': 'Palabra sobre el título',
'journey.studio.lockup.photo-word': 'Foto en las letras',
'journey.studio.lockup.fabric-band': 'Título sobre una banda',
'journey.studio.lockup.arched-title': 'Título en arco',
'journey.studio.lockup.retro-shadow': 'Sombra retro',
'journey.studio.lockup.outline-echo': 'Eco de contornos',
'journey.studio.lockup.minimal-caps': 'Mayúsculas espaciadas',
'journey.studio.lockup.circle-badge': 'Insignia redonda',
'journey.studio.lockup.postmark': 'Matasellos',
'journey.studio.lockup.stamp': 'Sello',
'journey.studio.lockup.ticket': 'Billete',
'journey.studio.lockup.chapter-outline': 'Número perfilado',
'journey.studio.lockup.chapter-rules': 'Número entre filetes',
'journey.studio.lockup.day-opener': 'Apertura del día',
'journey.studio.lockup.quote-classic': 'Cita clásica',
'journey.studio.lockup.quote-band': 'Cita sobre bandas',
'journey.studio.lockup.caption-rule': 'Pie de foto con filete',
'journey.studio.lockup.caption-number': 'Pie de foto numerado',
'journey.studio.lockup.coords-pin': 'Coordenadas con chincheta',
'journey.studio.lockup.coords-stack': 'Lugar y coordenadas',
'journey.studio.lockupWord.title': 'Lisboa',
'journey.studio.lockupWord.subtitle': 'Un verano junto al mar',
'journey.studio.lockupWord.kicker': 'Diario de viaje',
'journey.studio.lockupWord.overlay': 'viaje',
'journey.studio.lockupWord.place': 'Portugal', // same-as-en
'journey.studio.lockupWord.dates': 'Del 12 al 26 de agosto',
'journey.studio.lockupWord.quote': 'Las mejores vistas llegan tras las subidas más duras.',
'journey.studio.lockupWord.caption': 'Alfama con la primera luz',
'journey.studio.lockupWord.label': 'Día',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Liiga palju kirjatüüpe',
'assistant.card.book.finding.palette_drift': 'Omavahel sobimatud värvid',
'assistant.card.book.finding.repeat_layout': 'Ikka ja jälle sama paigutus',
'assistant.card.book.finding.curve_overflow': 'Kõverdatud tekst ulatub üle oma kaare',
'assistant.card.book.finding.effect_heavy': 'Liiga paks kontuur',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Täheregister ja asend',
'journey.studio.fx.case': 'Täheregister',
'journey.studio.fx.caseNone': 'Nagu kirjutatud',
'journey.studio.fx.caseUpper': 'Suurtähed',
'journey.studio.fx.caseLower': 'Väiketähed',
'journey.studio.fx.caseSmall': 'Kapiteelkiri',
'journey.studio.fx.vertical': 'Raamis',
'journey.studio.fx.top': 'Üleval',
'journey.studio.fx.middle': 'Keskel',
'journey.studio.fx.bottom': 'All',
'journey.studio.fx.fit': 'Sobita raami',
'journey.studio.fx.fitHint':
'Määratud suurus on suurim, milles tekst laotakse. Tekst kahaneb, kui raam jääb väikseks, ja kasvab tagasi, kui ruumi on.',
'journey.studio.fx.effects': 'Efektid',
'journey.studio.fx.shadow': 'Terav vari',
'journey.studio.fx.across': 'Külgsuunas',
'journey.studio.fx.down': 'Allapoole',
'journey.studio.fx.band': 'Markeririba',
'journey.studio.fx.padAcross': 'Ruum külgedel',
'journey.studio.fx.padDown': 'Ruum üleval ja all',
'journey.studio.fx.bandDrawn': 'Kõverdatud ja fotoga täidetud tekst joonistatakse ilma markeriribata.',
'journey.studio.fx.curve': 'Kõverus',
'journey.studio.fx.straight': 'Sirge',
'journey.studio.fx.arc': 'Kaar',
'journey.studio.fx.circle': 'Ring',
'journey.studio.fx.bend': 'Painutus',
'journey.studio.fx.start': 'Keskpunkt',
'journey.studio.fx.letters': 'Tähed',
'journey.studio.fx.hollow': 'Õõnes',
'journey.studio.fx.noPhotos': 'Sellel reisil pole veel fotosid, millega tähti täita.',
'journey.studio.fx.pickPhoto': 'Foto tähtede sees',
'journey.studio.fx.focalHint': 'Lohista, et valida, milline osa fotost tähtede kaudu paistab.',
'journey.studio.fx.zoom': 'Suum',
'journey.studio.fx.drawnHint':
'Kõverdatud ja fotoga täidetud tekst ei murdu: kaar kannab ühte rida, fototäide alustab uut rida seal, kus vajutad Enterit.',
'journey.studio.textStylesHint':
'Iga stiil laotakse sinu raamatu pealkirjaga ja sobitub sellega. See saabub valituna ühe komplektina, nii et saad selle korraga liigutada; klõpsa üksikul osal, et selle sõnu muuta.',
'journey.studio.lockupGroup.titles': 'Pealkirjad',
'journey.studio.lockupGroup.badges': 'Märgid ja templid',
'journey.studio.lockupGroup.chapters': 'Peatükid',
'journey.studio.lockupGroup.quotes': 'Tsitaadid, pildiallkirjad ja kohad',
'journey.studio.lockup.poster-stack': 'Plakatipinu',
'journey.studio.lockup.spaced-date': 'Kuupäevarida ja pealkiri',
'journey.studio.lockup.serif-sun': 'Seriifpealkiri päikesega',
'journey.studio.lockup.title-script-year': 'Pealkiri kaldkirjareaga',
'journey.studio.lockup.script-overlay': 'Sõna pealkirja kohal',
'journey.studio.lockup.photo-word': 'Foto tähtedes',
'journey.studio.lockup.fabric-band': 'Pealkiri lindil',
'journey.studio.lockup.arched-title': 'Kaarjas pealkiri',
'journey.studio.lockup.retro-shadow': 'Retrovari',
'journey.studio.lockup.outline-echo': 'Kontuurikaja',
'journey.studio.lockup.minimal-caps': 'Hõredad suurtähed',
'journey.studio.lockup.circle-badge': 'Ümar märk',
'journey.studio.lockup.postmark': 'Postitempel',
'journey.studio.lockup.stamp': 'Tempel',
'journey.studio.lockup.ticket': 'Pilet',
'journey.studio.lockup.chapter-outline': 'Kontuuriga number',
'journey.studio.lockup.chapter-rules': 'Number joonte vahel',
'journey.studio.lockup.day-opener': 'Päeva avaleht',
'journey.studio.lockup.quote-classic': 'Klassikaline tsitaat',
'journey.studio.lockup.quote-band': 'Tsitaat ribadel',
'journey.studio.lockup.caption-rule': 'Pildiallkiri joonega',
'journey.studio.lockup.caption-number': 'Nummerdatud pildiallkiri',
'journey.studio.lockup.coords-pin': 'Koordinaadid nööpnõelaga',
'journey.studio.lockup.coords-stack': 'Koht ja koordinaadid',
'journey.studio.lockupWord.title': 'Lissabon',
'journey.studio.lockupWord.subtitle': 'Suvi mere ääres',
'journey.studio.lockupWord.kicker': 'Reisipäevik',
'journey.studio.lockupWord.overlay': 'reis',
'journey.studio.lockupWord.place': 'Portugal', // same-as-en
'journey.studio.lockupWord.dates': '12. kuni 26. august',
'journey.studio.lockupWord.quote': 'Kõige ilusamad vaated tulevad pärast kõige raskemaid tõuse.',
'journey.studio.lockupWord.caption': 'Alfama esimeses valguses',
'journey.studio.lockupWord.label': 'Päev',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Trop de polices',
'assistant.card.book.finding.palette_drift': 'Des couleurs qui ne s’accordent pas',
'assistant.card.book.finding.repeat_layout': 'Toujours la même mise en page',
'assistant.card.book.finding.curve_overflow': 'Le texte courbé dépasse sa courbe',
'assistant.card.book.finding.effect_heavy': 'Un contour trop épais',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Casse et position',
'journey.studio.fx.case': 'Casse',
'journey.studio.fx.caseNone': 'Comme saisi',
'journey.studio.fx.caseUpper': 'Majuscules',
'journey.studio.fx.caseLower': 'Minuscules',
'journey.studio.fx.caseSmall': 'Petites capitales',
'journey.studio.fx.vertical': 'Dans le cadre',
'journey.studio.fx.top': 'Haut',
'journey.studio.fx.middle': 'Milieu',
'journey.studio.fx.bottom': 'Bas',
'journey.studio.fx.fit': 'Ajuster au cadre',
'journey.studio.fx.fitHint':
"La taille choisie est la plus grande utilisée. Le texte rétrécit quand le cadre devient trop petit et reprend sa taille dès qu'il y a de la place.",
'journey.studio.fx.effects': 'Effets',
'journey.studio.fx.shadow': 'Ombre nette',
'journey.studio.fx.across': 'Horizontal',
'journey.studio.fx.down': 'Vertical',
'journey.studio.fx.band': 'Bande surligneur',
'journey.studio.fx.padAcross': 'Marge sur les côtés',
'journey.studio.fx.padDown': 'Marge en haut et en bas',
'journey.studio.fx.bandDrawn': "Le texte courbé ou rempli d'une photo est dessiné sans bande surligneur.",
'journey.studio.fx.curve': 'Courbe',
'journey.studio.fx.straight': 'Droit',
'journey.studio.fx.arc': 'Arc', // same-as-en
'journey.studio.fx.circle': 'Cercle',
'journey.studio.fx.bend': 'Courbure',
'journey.studio.fx.start': 'Centré à',
'journey.studio.fx.letters': 'Lettres',
'journey.studio.fx.hollow': 'Évidé',
'journey.studio.fx.noPhotos': "Ce voyage n'a pas encore de photos pour remplir les lettres.",
'journey.studio.fx.pickPhoto': 'Photo dans les lettres',
'journey.studio.fx.focalHint': 'Faites glisser pour choisir la partie de la photo visible dans les lettres.',
'journey.studio.fx.zoom': 'Zoom', // same-as-en
'journey.studio.fx.drawnHint':
"Le texte courbé ou rempli d'une photo ne passe pas à la ligne : une courbe tient sur une ligne, un remplissage photo commence une nouvelle ligne là où vous appuyez sur Entrée.",
'journey.studio.textStylesHint':
"Chaque style est composé avec le titre de votre livre et s'y adapte. Il arrive sélectionné d'un bloc pour le déplacer en une fois ; cliquez sur une partie pour en changer les mots.",
'journey.studio.lockupGroup.titles': 'Titres',
'journey.studio.lockupGroup.badges': 'Badges et tampons',
'journey.studio.lockupGroup.chapters': 'Chapitres',
'journey.studio.lockupGroup.quotes': 'Citations, légendes et lieux',
'journey.studio.lockup.poster-stack': 'Affiche empilée',
'journey.studio.lockup.spaced-date': 'Ligne de date et titre',
'journey.studio.lockup.serif-sun': 'Titre à empattements et soleil',
'journey.studio.lockup.title-script-year': 'Titre et ligne en italique',
'journey.studio.lockup.script-overlay': 'Mot sur le titre',
'journey.studio.lockup.photo-word': 'Photo dans les lettres',
'journey.studio.lockup.fabric-band': 'Titre sur bandeau',
'journey.studio.lockup.arched-title': 'Titre en arc',
'journey.studio.lockup.retro-shadow': 'Ombre rétro',
'journey.studio.lockup.outline-echo': 'Écho de contours',
'journey.studio.lockup.minimal-caps': 'Capitales espacées',
'journey.studio.lockup.circle-badge': 'Badge rond',
'journey.studio.lockup.postmark': 'Cachet postal',
'journey.studio.lockup.stamp': 'Tampon',
'journey.studio.lockup.ticket': 'Billet',
'journey.studio.lockup.chapter-outline': 'Chiffre détouré',
'journey.studio.lockup.chapter-rules': 'Chiffre entre filets',
'journey.studio.lockup.day-opener': 'Ouverture du jour',
'journey.studio.lockup.quote-classic': 'Citation classique',
'journey.studio.lockup.quote-band': 'Citation sur bandes',
'journey.studio.lockup.caption-rule': 'Légende avec filet',
'journey.studio.lockup.caption-number': 'Légende numérotée',
'journey.studio.lockup.coords-pin': 'Coordonnées avec épingle',
'journey.studio.lockup.coords-stack': 'Lieu et coordonnées',
'journey.studio.lockupWord.title': 'Lisbonne',
'journey.studio.lockupWord.subtitle': 'Un été au bord de la mer',
'journey.studio.lockupWord.kicker': 'Carnet de voyage',
'journey.studio.lockupWord.overlay': 'voyage',
'journey.studio.lockupWord.place': 'Portugal', // same-as-en
'journey.studio.lockupWord.dates': 'Du 12 au 26 août',
'journey.studio.lockupWord.quote': 'Les plus belles vues viennent après les montées les plus dures.',
'journey.studio.lockupWord.caption': "L'Alfama aux premières lueurs",
'journey.studio.lockupWord.label': 'Jour',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Πάρα πολλές γραμματοσειρές',
'assistant.card.book.finding.palette_drift': 'Χρώματα που δεν ταιριάζουν',
'assistant.card.book.finding.repeat_layout': 'Η ίδια διάταξη ξανά και ξανά',
'assistant.card.book.finding.curve_overflow': 'Το καμπύλο κείμενο ξεπερνά την καμπύλη του',
'assistant.card.book.finding.effect_heavy': 'Πολύ παχύ περίγραμμα',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+80
View File
@@ -0,0 +1,80 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Πεζά, κεφαλαία και θέση',
'journey.studio.fx.case': 'Πεζά και κεφαλαία',
'journey.studio.fx.caseNone': 'Όπως γράφτηκε',
'journey.studio.fx.caseUpper': 'Κεφαλαία',
'journey.studio.fx.caseLower': 'Πεζά',
'journey.studio.fx.caseSmall': 'Μικρά κεφαλαία',
'journey.studio.fx.vertical': 'Στο πλαίσιο',
'journey.studio.fx.top': 'Πάνω',
'journey.studio.fx.middle': 'Μέση',
'journey.studio.fx.bottom': 'Κάτω',
'journey.studio.fx.fit': 'Προσαρμογή στο πλαίσιο',
'journey.studio.fx.fitHint':
'Το μέγεθος που ορίζεις είναι το μεγαλύτερο. Το κείμενο μικραίνει όταν το πλαίσιο γίνει πολύ μικρό και μεγαλώνει ξανά όταν υπάρχει χώρος.',
'journey.studio.fx.effects': 'Εφέ',
'journey.studio.fx.shadow': 'Έντονη σκιά',
'journey.studio.fx.across': 'Οριζόντια',
'journey.studio.fx.down': 'Κάθετα',
'journey.studio.fx.band': 'Λωρίδα υπογράμμισης',
'journey.studio.fx.padAcross': 'Χώρος στα πλάγια',
'journey.studio.fx.padDown': 'Χώρος πάνω και κάτω',
'journey.studio.fx.bandDrawn':
'Το καμπύλο κείμενο και το κείμενο με φωτογραφία σχεδιάζονται χωρίς λωρίδα υπογράμμισης.',
'journey.studio.fx.curve': 'Καμπύλη',
'journey.studio.fx.straight': 'Ευθεία',
'journey.studio.fx.arc': 'Τόξο',
'journey.studio.fx.circle': 'Κύκλος',
'journey.studio.fx.bend': 'Κάμψη',
'journey.studio.fx.start': 'Κέντρο στις',
'journey.studio.fx.letters': 'Γράμματα',
'journey.studio.fx.hollow': 'Κούφια',
'journey.studio.fx.noPhotos': 'Αυτό το ταξίδι δεν έχει ακόμη φωτογραφίες για να γεμίσουν τα γράμματα.',
'journey.studio.fx.pickPhoto': 'Φωτογραφία μέσα στα γράμματα',
'journey.studio.fx.focalHint': 'Σύρε για να επιλέξεις ποιο μέρος της φωτογραφίας φαίνεται μέσα από τα γράμματα.',
'journey.studio.fx.zoom': 'Ζουμ',
'journey.studio.fx.drawnHint':
'Το καμπύλο κείμενο και το κείμενο με φωτογραφία δεν αναδιπλώνονται: ένα τόξο παίρνει μία γραμμή, ένα γέμισμα με φωτογραφία αλλάζει γραμμή όπου πατάς Enter.',
'journey.studio.textStylesHint':
'Κάθε στυλ στοιχειοθετείται με τον τίτλο του βιβλίου σου και προσαρμόζεται σε αυτόν. Έρχεται επιλεγμένο ως ένα σύνολο, ώστε να το μετακινείς μονομιάς· κάνε κλικ σε ένα μέρος για να αλλάξεις τις λέξεις του.',
'journey.studio.lockupGroup.titles': 'Τίτλοι',
'journey.studio.lockupGroup.badges': 'Σήματα και σφραγίδες',
'journey.studio.lockupGroup.chapters': 'Κεφάλαια',
'journey.studio.lockupGroup.quotes': 'Αποσπάσματα, λεζάντες και τόποι',
'journey.studio.lockup.poster-stack': 'Στοίβα αφίσας',
'journey.studio.lockup.spaced-date': 'Γραμμή ημερομηνίας και τίτλος',
'journey.studio.lockup.serif-sun': 'Πατονοφόρος τίτλος με ήλιο',
'journey.studio.lockup.title-script-year': 'Τίτλος με πλάγια γραμμή',
'journey.studio.lockup.script-overlay': 'Λέξη πάνω από τον τίτλο',
'journey.studio.lockup.photo-word': 'Φωτογραφία στα γράμματα',
'journey.studio.lockup.fabric-band': 'Τίτλος σε ταινία',
'journey.studio.lockup.arched-title': 'Τοξωτός τίτλος',
'journey.studio.lockup.retro-shadow': 'Ρετρό σκιά',
'journey.studio.lockup.outline-echo': 'Ηχώ περιγράμματος',
'journey.studio.lockup.minimal-caps': 'Αραιά κεφαλαία',
'journey.studio.lockup.circle-badge': 'Στρογγυλό σήμα',
'journey.studio.lockup.postmark': 'Ταχυδρομική σφραγίδα',
'journey.studio.lockup.stamp': 'Σφραγίδα',
'journey.studio.lockup.ticket': 'Εισιτήριο',
'journey.studio.lockup.chapter-outline': 'Αριθμός με περίγραμμα',
'journey.studio.lockup.chapter-rules': 'Αριθμός ανάμεσα σε γραμμές',
'journey.studio.lockup.day-opener': 'Άνοιγμα ημέρας',
'journey.studio.lockup.quote-classic': 'Κλασικό απόσπασμα',
'journey.studio.lockup.quote-band': 'Απόσπασμα σε ταινίες',
'journey.studio.lockup.caption-rule': 'Λεζάντα με γραμμή',
'journey.studio.lockup.caption-number': 'Αριθμημένη λεζάντα',
'journey.studio.lockup.coords-pin': 'Συντεταγμένες με πινέζα',
'journey.studio.lockup.coords-stack': 'Τόπος και συντεταγμένες',
'journey.studio.lockupWord.title': 'Λισαβόνα',
'journey.studio.lockupWord.subtitle': 'Ένα καλοκαίρι δίπλα στη θάλασσα',
'journey.studio.lockupWord.kicker': 'Ταξιδιωτικό ημερολόγιο',
'journey.studio.lockupWord.overlay': 'ταξίδι',
'journey.studio.lockupWord.place': 'Πορτογαλία',
'journey.studio.lockupWord.dates': '12 έως 26 Αυγούστου',
'journey.studio.lockupWord.quote': 'Η πιο όμορφη θέα έρχεται μετά τις πιο δύσκολες ανηφόρες.',
'journey.studio.lockupWord.caption': 'Η Αλφάμα στο πρώτο φως',
'journey.studio.lockupWord.label': 'Ημέρα',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Túl sok betűtípus',
'assistant.card.book.finding.palette_drift': 'Össze nem illő színek',
'assistant.card.book.finding.repeat_layout': 'Újra és újra ugyanaz az elrendezés',
'assistant.card.book.finding.curve_overflow': 'Az ívelt szöveg túlfut az ívén',
'assistant.card.book.finding.effect_heavy': 'Túl vastag körvonal',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Betűforma és elhelyezés',
'journey.studio.fx.case': 'Betűforma',
'journey.studio.fx.caseNone': 'Ahogy beírtad',
'journey.studio.fx.caseUpper': 'Nagybetűk',
'journey.studio.fx.caseLower': 'Kisbetűk',
'journey.studio.fx.caseSmall': 'Kiskapitális',
'journey.studio.fx.vertical': 'A keretben',
'journey.studio.fx.top': 'Fent',
'journey.studio.fx.middle': 'Középen',
'journey.studio.fx.bottom': 'Lent',
'journey.studio.fx.fit': 'Kerethez igazítás',
'journey.studio.fx.fitHint':
'A beállított méret a legnagyobb, amekkorával a szöveg megjelenik. Kisebb lesz, ha a keret túl kicsi, és újra nő, amint van hely.',
'journey.studio.fx.effects': 'Effektek',
'journey.studio.fx.shadow': 'Éles árnyék',
'journey.studio.fx.across': 'Vízszintesen',
'journey.studio.fx.down': 'Függőlegesen',
'journey.studio.fx.band': 'Kiemelő sáv',
'journey.studio.fx.padAcross': 'Hely oldalt',
'journey.studio.fx.padDown': 'Hely fent és lent',
'journey.studio.fx.bandDrawn': 'Az ívelt és fotóval kitöltött szöveg kiemelő sáv nélkül jelenik meg.',
'journey.studio.fx.curve': 'Ív',
'journey.studio.fx.straight': 'Egyenes',
'journey.studio.fx.arc': 'Körív',
'journey.studio.fx.circle': 'Kör',
'journey.studio.fx.bend': 'Hajlítás',
'journey.studio.fx.start': 'Közepe itt',
'journey.studio.fx.letters': 'Betűk',
'journey.studio.fx.hollow': 'Üreges',
'journey.studio.fx.noPhotos': 'Ennek az útnak még nincsenek fotói, amelyekkel kitölthetnéd a betűket.',
'journey.studio.fx.pickPhoto': 'Fotó a betűkben',
'journey.studio.fx.focalHint': 'Húzd, hogy kiválaszd, a fotó melyik része látszik a betűkön át.',
'journey.studio.fx.zoom': 'Nagyítás',
'journey.studio.fx.drawnHint':
'Az ívelt és fotóval kitöltött szöveg nem tördelődik: az ív egy sort visz, a fotókitöltés ott kezd új sort, ahol Entert nyomsz.',
'journey.studio.textStylesHint':
'Minden stílus a könyved címével készül, és hozzá igazodik. Egy egységként kijelölve érkezik, így egyszerre mozgathatod; egy részére kattintva módosíthatod a szavait.',
'journey.studio.lockupGroup.titles': 'Címek',
'journey.studio.lockupGroup.badges': 'Jelvények és bélyegzők',
'journey.studio.lockupGroup.chapters': 'Fejezetek',
'journey.studio.lockupGroup.quotes': 'Idézetek, képaláírások és helyek',
'journey.studio.lockup.poster-stack': 'Plakátoszlop',
'journey.studio.lockup.spaced-date': 'Dátumsor és cím',
'journey.studio.lockup.serif-sun': 'Talpas cím nappal',
'journey.studio.lockup.title-script-year': 'Cím dőlt sorral',
'journey.studio.lockup.script-overlay': 'Szó a cím felett',
'journey.studio.lockup.photo-word': 'Fotó a betűkben',
'journey.studio.lockup.fabric-band': 'Cím szalagon',
'journey.studio.lockup.arched-title': 'Ívelt cím',
'journey.studio.lockup.retro-shadow': 'Retró árnyék',
'journey.studio.lockup.outline-echo': 'Körvonalvisszhang',
'journey.studio.lockup.minimal-caps': 'Ritkított nagybetűk',
'journey.studio.lockup.circle-badge': 'Kerek jelvény',
'journey.studio.lockup.postmark': 'Postabélyegző',
'journey.studio.lockup.stamp': 'Bélyegző',
'journey.studio.lockup.ticket': 'Jegy',
'journey.studio.lockup.chapter-outline': 'Körvonalas szám',
'journey.studio.lockup.chapter-rules': 'Szám vonalak között',
'journey.studio.lockup.day-opener': 'Napnyitó',
'journey.studio.lockup.quote-classic': 'Klasszikus idézet',
'journey.studio.lockup.quote-band': 'Idézet sávokon',
'journey.studio.lockup.caption-rule': 'Képaláírás vonallal',
'journey.studio.lockup.caption-number': 'Számozott képaláírás',
'journey.studio.lockup.coords-pin': 'Koordináták tűvel',
'journey.studio.lockup.coords-stack': 'Hely és koordináták',
'journey.studio.lockupWord.title': 'Lisszabon',
'journey.studio.lockupWord.subtitle': 'Egy nyár a tengernél',
'journey.studio.lockupWord.kicker': 'Útinapló',
'journey.studio.lockupWord.overlay': 'utazás',
'journey.studio.lockupWord.place': 'Portugália',
'journey.studio.lockupWord.dates': 'Augusztus 12. és 26. között',
'journey.studio.lockupWord.quote': 'A legszebb kilátás a legnehezebb emelkedők után jön.',
'journey.studio.lockupWord.caption': 'Alfama az első fényben',
'journey.studio.lockupWord.label': 'Nap',
};
export default studioText;
+2
View File
@@ -45,5 +45,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Terlalu banyak jenis huruf',
'assistant.card.book.finding.palette_drift': 'Warna yang tidak serasi',
'assistant.card.book.finding.repeat_layout': 'Tata letak yang sama berulang-ulang',
'assistant.card.book.finding.curve_overflow': 'Teks melengkung melewati lengkungannya',
'assistant.card.book.finding.effect_heavy': 'Garis tepi yang terlalu tebal',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Huruf dan posisi',
'journey.studio.fx.case': 'Huruf',
'journey.studio.fx.caseNone': 'Seperti diketik',
'journey.studio.fx.caseUpper': 'Huruf kapital',
'journey.studio.fx.caseLower': 'Huruf kecil',
'journey.studio.fx.caseSmall': 'Kapital kecil',
'journey.studio.fx.vertical': 'Dalam bingkai',
'journey.studio.fx.top': 'Atas',
'journey.studio.fx.middle': 'Tengah',
'journey.studio.fx.bottom': 'Bawah',
'journey.studio.fx.fit': 'Sesuaikan dengan bingkai',
'journey.studio.fx.fitHint':
'Ukuran yang Anda atur adalah ukuran terbesar. Teks mengecil saat bingkai terlalu kecil dan membesar lagi saat ada ruang.',
'journey.studio.fx.effects': 'Efek',
'journey.studio.fx.shadow': 'Bayangan tegas',
'journey.studio.fx.across': 'Ke samping',
'journey.studio.fx.down': 'Ke bawah',
'journey.studio.fx.band': 'Pita stabilo',
'journey.studio.fx.padAcross': 'Ruang di samping',
'journey.studio.fx.padDown': 'Ruang di atas dan bawah',
'journey.studio.fx.bandDrawn': 'Teks melengkung atau berisi foto digambar tanpa pita stabilo.',
'journey.studio.fx.curve': 'Lengkungan',
'journey.studio.fx.straight': 'Lurus',
'journey.studio.fx.arc': 'Busur',
'journey.studio.fx.circle': 'Lingkaran',
'journey.studio.fx.bend': 'Kelengkungan',
'journey.studio.fx.start': 'Tengah di',
'journey.studio.fx.letters': 'Huruf-huruf',
'journey.studio.fx.hollow': 'Berongga',
'journey.studio.fx.noPhotos': 'Perjalanan ini belum punya foto untuk mengisi huruf.',
'journey.studio.fx.pickPhoto': 'Foto di dalam huruf',
'journey.studio.fx.focalHint': 'Seret untuk memilih bagian foto yang tampak di dalam huruf.',
'journey.studio.fx.zoom': 'Perbesaran',
'journey.studio.fx.drawnHint':
'Teks melengkung atau berisi foto tidak dibungkus: busur memuat satu baris, isian foto memulai baris baru di tempat Anda menekan Enter.',
'journey.studio.textStylesHint':
'Setiap gaya disusun dengan judul buku Anda dan menyesuaikan diri dengannya. Gaya tiba terpilih sebagai satu set sehingga bisa dipindahkan sekaligus; klik satu bagian untuk mengubah kata-katanya.',
'journey.studio.lockupGroup.titles': 'Judul',
'journey.studio.lockupGroup.badges': 'Lencana dan stempel',
'journey.studio.lockupGroup.chapters': 'Bab',
'journey.studio.lockupGroup.quotes': 'Kutipan, keterangan, dan tempat',
'journey.studio.lockup.poster-stack': 'Tumpukan poster',
'journey.studio.lockup.spaced-date': 'Baris tanggal dan judul',
'journey.studio.lockup.serif-sun': 'Judul serif dengan matahari',
'journey.studio.lockup.title-script-year': 'Judul dengan baris miring',
'journey.studio.lockup.script-overlay': 'Kata di atas judul',
'journey.studio.lockup.photo-word': 'Foto di dalam huruf',
'journey.studio.lockup.fabric-band': 'Judul di atas pita',
'journey.studio.lockup.arched-title': 'Judul melengkung',
'journey.studio.lockup.retro-shadow': 'Bayangan retro',
'journey.studio.lockup.outline-echo': 'Gema garis tepi',
'journey.studio.lockup.minimal-caps': 'Kapital berjarak',
'journey.studio.lockup.circle-badge': 'Lencana bundar',
'journey.studio.lockup.postmark': 'Cap pos',
'journey.studio.lockup.stamp': 'Stempel',
'journey.studio.lockup.ticket': 'Tiket',
'journey.studio.lockup.chapter-outline': 'Angka bergaris tepi',
'journey.studio.lockup.chapter-rules': 'Angka di antara garis',
'journey.studio.lockup.day-opener': 'Pembuka hari',
'journey.studio.lockup.quote-classic': 'Kutipan klasik',
'journey.studio.lockup.quote-band': 'Kutipan di atas pita',
'journey.studio.lockup.caption-rule': 'Keterangan dengan garis',
'journey.studio.lockup.caption-number': 'Keterangan bernomor',
'journey.studio.lockup.coords-pin': 'Koordinat dengan pin',
'journey.studio.lockup.coords-stack': 'Tempat dan koordinat',
'journey.studio.lockupWord.title': 'Lisabon',
'journey.studio.lockupWord.subtitle': 'Musim panas di tepi laut',
'journey.studio.lockupWord.kicker': 'Jurnal perjalanan',
'journey.studio.lockupWord.overlay': 'perjalanan',
'journey.studio.lockupWord.place': 'Portugal', // same-as-en
'journey.studio.lockupWord.dates': '12 sampai 26 Agustus',
'journey.studio.lockupWord.quote': 'Pemandangan terindah datang setelah tanjakan tersulit.',
'journey.studio.lockupWord.caption': 'Alfama di cahaya pertama',
'journey.studio.lockupWord.label': 'Hari',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Troppi caratteri tipografici',
'assistant.card.book.finding.palette_drift': 'Colori che non si accordano',
'assistant.card.book.finding.repeat_layout': 'Sempre lo stesso layout',
'assistant.card.book.finding.curve_overflow': 'Il testo curvo supera la sua curva',
'assistant.card.book.finding.effect_heavy': 'Un contorno troppo spesso',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Maiuscole e posizione',
'journey.studio.fx.case': 'Maiuscole',
'journey.studio.fx.caseNone': 'Come digitato',
'journey.studio.fx.caseUpper': 'Maiuscole',
'journey.studio.fx.caseLower': 'Minuscole',
'journey.studio.fx.caseSmall': 'Maiuscoletto',
'journey.studio.fx.vertical': 'Nella cornice',
'journey.studio.fx.top': 'In alto',
'journey.studio.fx.middle': 'Al centro',
'journey.studio.fx.bottom': 'In basso',
'journey.studio.fx.fit': 'Adatta alla cornice',
'journey.studio.fx.fitHint':
"La dimensione scelta è la più grande usata. Il testo si rimpicciolisce quando la cornice diventa troppo piccola e torna a crescere quando c'è spazio.",
'journey.studio.fx.effects': 'Effetti',
'journey.studio.fx.shadow': 'Ombra netta',
'journey.studio.fx.across': 'In orizzontale',
'journey.studio.fx.down': 'In verticale',
'journey.studio.fx.band': 'Banda evidenziatore',
'journey.studio.fx.padAcross': 'Spazio ai lati',
'journey.studio.fx.padDown': 'Spazio sopra e sotto',
'journey.studio.fx.bandDrawn': 'Il testo curvo o riempito con una foto viene disegnato senza banda evidenziatore.',
'journey.studio.fx.curve': 'Curva',
'journey.studio.fx.straight': 'Dritto',
'journey.studio.fx.arc': 'Arco',
'journey.studio.fx.circle': 'Cerchio',
'journey.studio.fx.bend': 'Curvatura',
'journey.studio.fx.start': 'Centrato a',
'journey.studio.fx.letters': 'Lettere',
'journey.studio.fx.hollow': 'Vuoto',
'journey.studio.fx.noPhotos': 'Questo viaggio non ha ancora foto con cui riempire le lettere.',
'journey.studio.fx.pickPhoto': 'Foto dentro le lettere',
'journey.studio.fx.focalHint': 'Trascina per scegliere quale parte della foto si vede attraverso le lettere.',
'journey.studio.fx.zoom': 'Zoom', // same-as-en
'journey.studio.fx.drawnHint':
'Il testo curvo o riempito con una foto non va a capo: una curva tiene una riga, un riempimento con foto inizia una nuova riga dove premi Invio.',
'journey.studio.textStylesHint':
'Ogni stile è composto con il titolo del tuo libro e si adatta a esso. Arriva selezionato come un insieme, così lo sposti in una volta; fai clic su una singola parte per cambiarne le parole.',
'journey.studio.lockupGroup.titles': 'Titoli',
'journey.studio.lockupGroup.badges': 'Badge e timbri',
'journey.studio.lockupGroup.chapters': 'Capitoli',
'journey.studio.lockupGroup.quotes': 'Citazioni, didascalie e luoghi',
'journey.studio.lockup.poster-stack': 'Manifesto impilato',
'journey.studio.lockup.spaced-date': 'Riga della data e titolo',
'journey.studio.lockup.serif-sun': 'Titolo graziato con sole',
'journey.studio.lockup.title-script-year': 'Titolo con riga in corsivo',
'journey.studio.lockup.script-overlay': 'Parola sopra il titolo',
'journey.studio.lockup.photo-word': 'Foto nelle lettere',
'journey.studio.lockup.fabric-band': 'Titolo su una fascia',
'journey.studio.lockup.arched-title': 'Titolo ad arco',
'journey.studio.lockup.retro-shadow': 'Ombra rétro',
'journey.studio.lockup.outline-echo': 'Eco di contorni',
'journey.studio.lockup.minimal-caps': 'Maiuscole spaziate',
'journey.studio.lockup.circle-badge': 'Badge rotondo',
'journey.studio.lockup.postmark': 'Timbro postale',
'journey.studio.lockup.stamp': 'Timbro',
'journey.studio.lockup.ticket': 'Biglietto',
'journey.studio.lockup.chapter-outline': 'Numero con contorno',
'journey.studio.lockup.chapter-rules': 'Numero tra filetti',
'journey.studio.lockup.day-opener': 'Apertura del giorno',
'journey.studio.lockup.quote-classic': 'Citazione classica',
'journey.studio.lockup.quote-band': 'Citazione su fasce',
'journey.studio.lockup.caption-rule': 'Didascalia con filetto',
'journey.studio.lockup.caption-number': 'Didascalia numerata',
'journey.studio.lockup.coords-pin': 'Coordinate con spillo',
'journey.studio.lockup.coords-stack': 'Luogo e coordinate',
'journey.studio.lockupWord.title': 'Lisbona',
'journey.studio.lockupWord.subtitle': "Un'estate al mare",
'journey.studio.lockupWord.kicker': 'Diario di viaggio',
'journey.studio.lockupWord.overlay': 'viaggio',
'journey.studio.lockupWord.place': 'Portogallo',
'journey.studio.lockupWord.dates': 'Dal 12 al 26 agosto',
'journey.studio.lockupWord.quote': 'Le viste più belle arrivano dopo le salite più dure.',
'journey.studio.lockupWord.caption': "L'Alfama alle prime luci",
'journey.studio.lockupWord.label': 'Giorno',
};
export default studioText;
+2
View File
@@ -45,5 +45,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'フォントが多すぎます',
'assistant.card.book.finding.palette_drift': '合わない色',
'assistant.card.book.finding.repeat_layout': '同じレイアウトの繰り返し',
'assistant.card.book.finding.curve_overflow': '曲線の文字がカーブからはみ出しています',
'assistant.card.book.finding.effect_heavy': '太すぎる輪郭',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+78
View File
@@ -0,0 +1,78 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': '大文字小文字と配置',
'journey.studio.fx.case': '大文字小文字',
'journey.studio.fx.caseNone': '入力どおり',
'journey.studio.fx.caseUpper': '大文字',
'journey.studio.fx.caseLower': '小文字',
'journey.studio.fx.caseSmall': 'スモールキャップス',
'journey.studio.fx.vertical': '枠内の位置',
'journey.studio.fx.top': '上',
'journey.studio.fx.middle': '中央',
'journey.studio.fx.bottom': '下',
'journey.studio.fx.fit': '枠に合わせて縮小',
'journey.studio.fx.fitHint': '設定したサイズが最大です。枠が小さすぎると文字が縮み、余裕ができると元に戻ります。',
'journey.studio.fx.effects': '効果',
'journey.studio.fx.shadow': 'くっきりした影',
'journey.studio.fx.across': '横',
'journey.studio.fx.down': '縦',
'journey.studio.fx.band': 'マーカー帯',
'journey.studio.fx.padAcross': '左右の余白',
'journey.studio.fx.padDown': '上下の余白',
'journey.studio.fx.bandDrawn': '曲線や写真で塗った文字にはマーカー帯は描かれません。',
'journey.studio.fx.curve': 'カーブ',
'journey.studio.fx.straight': 'まっすぐ',
'journey.studio.fx.arc': '弧',
'journey.studio.fx.circle': '円',
'journey.studio.fx.bend': '曲がり具合',
'journey.studio.fx.start': '中心の角度',
'journey.studio.fx.letters': '文字',
'journey.studio.fx.hollow': '中抜き',
'journey.studio.fx.noPhotos': 'この旅には文字を塗る写真がまだありません。',
'journey.studio.fx.pickPhoto': '文字の中の写真',
'journey.studio.fx.focalHint': 'ドラッグして、文字から見える写真の部分を選びます。',
'journey.studio.fx.zoom': 'ズーム',
'journey.studio.fx.drawnHint':
'曲線や写真で塗った文字は折り返しません。曲線は1行だけ、写真の塗りはEnterを押した位置で改行します。',
'journey.studio.textStylesHint':
'どのスタイルも本のタイトルで組まれ、それに合わせて大きさが決まります。ひとまとまりで選択された状態で置かれるので一度に動かせます。言葉を変えるときは一つの部分をクリックしてください。',
'journey.studio.lockupGroup.titles': 'タイトル',
'journey.studio.lockupGroup.badges': 'バッジとスタンプ',
'journey.studio.lockupGroup.chapters': '章',
'journey.studio.lockupGroup.quotes': '引用、キャプション、場所',
'journey.studio.lockup.poster-stack': 'ポスター風の積み重ね',
'journey.studio.lockup.spaced-date': '日付行とタイトル',
'journey.studio.lockup.serif-sun': '太陽を添えたセリフのタイトル',
'journey.studio.lockup.title-script-year': '斜体の行を添えたタイトル',
'journey.studio.lockup.script-overlay': 'タイトルに重ねた一語',
'journey.studio.lockup.photo-word': '写真入りの文字',
'journey.studio.lockup.fabric-band': '帯の上のタイトル',
'journey.studio.lockup.arched-title': 'アーチ状のタイトル',
'journey.studio.lockup.retro-shadow': 'レトロな影',
'journey.studio.lockup.outline-echo': '輪郭のエコー',
'journey.studio.lockup.minimal-caps': '字間を広げた大文字',
'journey.studio.lockup.circle-badge': '丸いバッジ',
'journey.studio.lockup.postmark': '消印',
'journey.studio.lockup.stamp': 'スタンプ',
'journey.studio.lockup.ticket': 'チケット',
'journey.studio.lockup.chapter-outline': '輪郭の数字',
'journey.studio.lockup.chapter-rules': '罫線に挟んだ数字',
'journey.studio.lockup.day-opener': '一日の始まり',
'journey.studio.lockup.quote-classic': 'クラシックな引用',
'journey.studio.lockup.quote-band': '帯に載せた引用',
'journey.studio.lockup.caption-rule': '罫線付きキャプション',
'journey.studio.lockup.caption-number': '番号付きキャプション',
'journey.studio.lockup.coords-pin': 'ピン付きの座標',
'journey.studio.lockup.coords-stack': '場所と座標',
'journey.studio.lockupWord.title': 'リスボン',
'journey.studio.lockupWord.subtitle': '海辺の夏',
'journey.studio.lockupWord.kicker': '旅の日記',
'journey.studio.lockupWord.overlay': '旅',
'journey.studio.lockupWord.place': 'ポルトガル',
'journey.studio.lockupWord.dates': '8月12日から26日',
'journey.studio.lockupWord.quote': 'いちばん美しい景色は、いちばんきつい登りのあとにやってくる。',
'journey.studio.lockupWord.caption': '朝の光のアルファマ',
'journey.studio.lockupWord.label': '日',
};
export default studioText;
+2
View File
@@ -45,5 +45,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': '글꼴이 너무 많습니다',
'assistant.card.book.finding.palette_drift': '어울리지 않는 색',
'assistant.card.book.finding.repeat_layout': '같은 레이아웃의 반복',
'assistant.card.book.finding.curve_overflow': '곡선 글자가 곡선을 넘어갑니다',
'assistant.card.book.finding.effect_heavy': '너무 두꺼운 윤곽선',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': '대소문자와 배치',
'journey.studio.fx.case': '대소문자',
'journey.studio.fx.caseNone': '입력한 대로',
'journey.studio.fx.caseUpper': '대문자',
'journey.studio.fx.caseLower': '소문자',
'journey.studio.fx.caseSmall': '작은 대문자',
'journey.studio.fx.vertical': '틀 안 위치',
'journey.studio.fx.top': '위',
'journey.studio.fx.middle': '가운데',
'journey.studio.fx.bottom': '아래',
'journey.studio.fx.fit': '틀에 맞게 줄이기',
'journey.studio.fx.fitHint':
'설정한 크기가 최대 크기입니다. 틀이 너무 작아지면 글자가 줄어들고, 공간이 생기면 다시 커집니다.',
'journey.studio.fx.effects': '효과',
'journey.studio.fx.shadow': '선명한 그림자',
'journey.studio.fx.across': '가로',
'journey.studio.fx.down': '세로',
'journey.studio.fx.band': '형광펜 띠',
'journey.studio.fx.padAcross': '좌우 여백',
'journey.studio.fx.padDown': '위아래 여백',
'journey.studio.fx.bandDrawn': '곡선 글자와 사진으로 채운 글자에는 형광펜 띠가 그려지지 않습니다.',
'journey.studio.fx.curve': '곡선',
'journey.studio.fx.straight': '직선',
'journey.studio.fx.arc': '호',
'journey.studio.fx.circle': '원',
'journey.studio.fx.bend': '휘는 정도',
'journey.studio.fx.start': '중심 각도',
'journey.studio.fx.letters': '글자',
'journey.studio.fx.hollow': '속이 빈',
'journey.studio.fx.noPhotos': '이 여행에는 아직 글자를 채울 사진이 없습니다.',
'journey.studio.fx.pickPhoto': '글자 속 사진',
'journey.studio.fx.focalHint': '드래그해서 글자 사이로 보일 사진 부분을 고르세요.',
'journey.studio.fx.zoom': '확대',
'journey.studio.fx.drawnHint':
'곡선 글자와 사진으로 채운 글자는 줄바꿈되지 않습니다. 곡선은 한 줄만, 사진 채우기는 Enter를 누른 곳에서 줄을 바꿉니다.',
'journey.studio.textStylesHint':
'각 스타일은 책 제목으로 조판되고 제목에 맞게 크기가 정해집니다. 한 묶음으로 선택된 채로 놓여 한 번에 옮길 수 있고, 글자를 바꾸려면 한 부분을 클릭하세요.',
'journey.studio.lockupGroup.titles': '제목',
'journey.studio.lockupGroup.badges': '배지와 스탬프',
'journey.studio.lockupGroup.chapters': '챕터',
'journey.studio.lockupGroup.quotes': '인용, 캡션, 장소',
'journey.studio.lockup.poster-stack': '포스터 스택',
'journey.studio.lockup.spaced-date': '날짜 줄과 제목',
'journey.studio.lockup.serif-sun': '해를 곁들인 세리프 제목',
'journey.studio.lockup.title-script-year': '기울임 줄이 있는 제목',
'journey.studio.lockup.script-overlay': '제목 위의 한 단어',
'journey.studio.lockup.photo-word': '글자 속 사진',
'journey.studio.lockup.fabric-band': '띠 위의 제목',
'journey.studio.lockup.arched-title': '아치형 제목',
'journey.studio.lockup.retro-shadow': '레트로 그림자',
'journey.studio.lockup.outline-echo': '윤곽선 에코',
'journey.studio.lockup.minimal-caps': '자간을 넓힌 대문자',
'journey.studio.lockup.circle-badge': '둥근 배지',
'journey.studio.lockup.postmark': '소인',
'journey.studio.lockup.stamp': '스탬프',
'journey.studio.lockup.ticket': '티켓',
'journey.studio.lockup.chapter-outline': '윤곽선 숫자',
'journey.studio.lockup.chapter-rules': '선 사이의 숫자',
'journey.studio.lockup.day-opener': '하루의 시작',
'journey.studio.lockup.quote-classic': '클래식 인용',
'journey.studio.lockup.quote-band': '띠 위의 인용',
'journey.studio.lockup.caption-rule': '선이 있는 캡션',
'journey.studio.lockup.caption-number': '번호 캡션',
'journey.studio.lockup.coords-pin': '핀이 있는 좌표',
'journey.studio.lockup.coords-stack': '장소와 좌표',
'journey.studio.lockupWord.title': '리스본',
'journey.studio.lockupWord.subtitle': '바닷가의 여름',
'journey.studio.lockupWord.kicker': '여행 일기',
'journey.studio.lockupWord.overlay': '여행',
'journey.studio.lockupWord.place': '포르투갈',
'journey.studio.lockupWord.dates': '8월 12일부터 26일까지',
'journey.studio.lockupWord.quote': '가장 멋진 풍경은 가장 힘든 오르막 뒤에 온다.',
'journey.studio.lockupWord.caption': '첫 햇살 속 알파마',
'journey.studio.lockupWord.label': '일',
};
export default studioText;
+2
View File
@@ -49,5 +49,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Te veel lettertypen',
'assistant.card.book.finding.palette_drift': 'Kleuren die niet bij elkaar passen',
'assistant.card.book.finding.repeat_layout': 'Steeds dezelfde lay-out',
'assistant.card.book.finding.curve_overflow': 'Gebogen tekst loopt voorbij zijn boog',
'assistant.card.book.finding.effect_heavy': 'Een te zware contour',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Hoofdletters en positie',
'journey.studio.fx.case': 'Hoofdletters',
'journey.studio.fx.caseNone': 'Zoals getypt',
'journey.studio.fx.caseUpper': 'Hoofdletters',
'journey.studio.fx.caseLower': 'Kleine letters',
'journey.studio.fx.caseSmall': 'Klein kapitaal',
'journey.studio.fx.vertical': 'In het kader',
'journey.studio.fx.top': 'Boven',
'journey.studio.fx.middle': 'Midden',
'journey.studio.fx.bottom': 'Onder',
'journey.studio.fx.fit': 'Passend maken',
'journey.studio.fx.fitHint':
'De ingestelde grootte is de grootste waarin de tekst wordt gezet. Hij krimpt als het kader te klein wordt en groeit weer zodra er ruimte is.',
'journey.studio.fx.effects': 'Effecten',
'journey.studio.fx.shadow': 'Harde schaduw',
'journey.studio.fx.across': 'Opzij',
'journey.studio.fx.down': 'Omlaag',
'journey.studio.fx.band': 'Markeerband',
'journey.studio.fx.padAcross': 'Ruimte aan de zijkanten',
'journey.studio.fx.padDown': 'Ruimte boven en onder',
'journey.studio.fx.bandDrawn': 'Gebogen en met een foto gevulde tekst wordt zonder markeerband getekend.',
'journey.studio.fx.curve': 'Kromming',
'journey.studio.fx.straight': 'Recht',
'journey.studio.fx.arc': 'Boog',
'journey.studio.fx.circle': 'Cirkel',
'journey.studio.fx.bend': 'Buiging',
'journey.studio.fx.start': 'Midden op',
'journey.studio.fx.letters': 'Letters', // same-as-en
'journey.studio.fx.hollow': 'Hol',
'journey.studio.fx.noPhotos': "Deze reis heeft nog geen foto's om de letters mee te vullen.",
'journey.studio.fx.pickPhoto': 'Foto in de letters',
'journey.studio.fx.focalHint': 'Sleep om te kiezen welk deel van de foto door de letters schijnt.',
'journey.studio.fx.zoom': 'Zoom', // same-as-en
'journey.studio.fx.drawnHint':
'Gebogen en met een foto gevulde tekst loopt niet door: een boog zet één regel, een fotovulling begint een nieuwe regel waar je op Enter drukt.',
'journey.studio.textStylesHint':
'Elke stijl wordt gezet met de titel van je boek en past zich daaraan aan. Hij komt geselecteerd als één geheel binnen, zodat je hem in één keer verplaatst; klik op een los onderdeel om de woorden te wijzigen.',
'journey.studio.lockupGroup.titles': 'Titels',
'journey.studio.lockupGroup.badges': 'Badges en stempels',
'journey.studio.lockupGroup.chapters': 'Hoofdstukken',
'journey.studio.lockupGroup.quotes': 'Citaten, bijschriften en plaatsen',
'journey.studio.lockup.poster-stack': 'Posterstapel',
'journey.studio.lockup.spaced-date': 'Datumregel en titel',
'journey.studio.lockup.serif-sun': 'Schreeftitel met zon',
'journey.studio.lockup.title-script-year': 'Titel met cursieve regel',
'journey.studio.lockup.script-overlay': 'Woord over de titel',
'journey.studio.lockup.photo-word': 'Foto in de letters',
'journey.studio.lockup.fabric-band': 'Titel op een band',
'journey.studio.lockup.arched-title': 'Gebogen titel',
'journey.studio.lockup.retro-shadow': 'Retroschaduw',
'journey.studio.lockup.outline-echo': 'Contourecho',
'journey.studio.lockup.minimal-caps': 'Gespatieerde kapitalen',
'journey.studio.lockup.circle-badge': 'Ronde badge',
'journey.studio.lockup.postmark': 'Poststempel',
'journey.studio.lockup.stamp': 'Stempel',
'journey.studio.lockup.ticket': 'Kaartje',
'journey.studio.lockup.chapter-outline': 'Omlijnd cijfer',
'journey.studio.lockup.chapter-rules': 'Cijfer tussen lijnen',
'journey.studio.lockup.day-opener': 'Dagopener',
'journey.studio.lockup.quote-classic': 'Klassiek citaat',
'journey.studio.lockup.quote-band': 'Citaat op banden',
'journey.studio.lockup.caption-rule': 'Bijschrift met lijn',
'journey.studio.lockup.caption-number': 'Genummerd bijschrift',
'journey.studio.lockup.coords-pin': 'Coördinaten met speld',
'journey.studio.lockup.coords-stack': 'Plaats en coördinaten',
'journey.studio.lockupWord.title': 'Lissabon',
'journey.studio.lockupWord.subtitle': 'Een zomer aan zee',
'journey.studio.lockupWord.kicker': 'Reisdagboek',
'journey.studio.lockupWord.overlay': 'reis',
'journey.studio.lockupWord.place': 'Portugal', // same-as-en
'journey.studio.lockupWord.dates': '12 tot 26 augustus',
'journey.studio.lockupWord.quote': 'De mooiste uitzichten komen na de zwaarste klimmen.',
'journey.studio.lockupWord.caption': 'Alfama in het eerste licht',
'journey.studio.lockupWord.label': 'Dag',
};
export default studioText;
+2
View File
@@ -57,5 +57,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Zbyt wiele krojów pisma',
'assistant.card.book.finding.palette_drift': 'Kolory, które do siebie nie pasują',
'assistant.card.book.finding.repeat_layout': 'Ciągle ten sam układ',
'assistant.card.book.finding.curve_overflow': 'Wygięty tekst wychodzi poza swój łuk',
'assistant.card.book.finding.effect_heavy': 'Zbyt gruby kontur',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Wielkość liter i położenie',
'journey.studio.fx.case': 'Wielkość liter',
'journey.studio.fx.caseNone': 'Jak wpisano',
'journey.studio.fx.caseUpper': 'Wielkie litery',
'journey.studio.fx.caseLower': 'Małe litery',
'journey.studio.fx.caseSmall': 'Kapitaliki',
'journey.studio.fx.vertical': 'W ramce',
'journey.studio.fx.top': 'Góra',
'journey.studio.fx.middle': 'Środek',
'journey.studio.fx.bottom': 'Dół',
'journey.studio.fx.fit': 'Dopasuj do ramki',
'journey.studio.fx.fitHint':
'Ustawiony rozmiar jest największym, w jakim składany jest tekst. Zmniejsza się, gdy ramka robi się za mała, i rośnie z powrotem, gdy jest miejsce.',
'journey.studio.fx.effects': 'Efekty',
'journey.studio.fx.shadow': 'Twardy cień',
'journey.studio.fx.across': 'W poziomie',
'journey.studio.fx.down': 'W pionie',
'journey.studio.fx.band': 'Pasek zakreślacza',
'journey.studio.fx.padAcross': 'Odstęp po bokach',
'journey.studio.fx.padDown': 'Odstęp u góry i u dołu',
'journey.studio.fx.bandDrawn': 'Tekst wygięty lub wypełniony zdjęciem jest rysowany bez paska zakreślacza.',
'journey.studio.fx.curve': 'Wygięcie',
'journey.studio.fx.straight': 'Prosto',
'journey.studio.fx.arc': 'Łuk',
'journey.studio.fx.circle': 'Okrąg',
'journey.studio.fx.bend': 'Krzywizna',
'journey.studio.fx.start': 'Środek na',
'journey.studio.fx.letters': 'Litery',
'journey.studio.fx.hollow': 'Puste',
'journey.studio.fx.noPhotos': 'Ta podróż nie ma jeszcze zdjęć, którymi można wypełnić litery.',
'journey.studio.fx.pickPhoto': 'Zdjęcie w literach',
'journey.studio.fx.focalHint': 'Przeciągnij, aby wybrać, którą część zdjęcia widać przez litery.',
'journey.studio.fx.zoom': 'Powiększenie',
'journey.studio.fx.drawnHint':
'Tekst wygięty lub wypełniony zdjęciem nie zawija się: łuk mieści jeden wiersz, wypełnienie zdjęciem zaczyna nowy wiersz tam, gdzie naciśniesz Enter.',
'journey.studio.textStylesHint':
'Każdy styl jest składany z tytułem twojej książki i dopasowuje się do niego. Pojawia się zaznaczony jako całość, więc przesuniesz go za jednym razem; kliknij pojedynczą część, aby zmienić jej słowa.',
'journey.studio.lockupGroup.titles': 'Tytuły',
'journey.studio.lockupGroup.badges': 'Odznaki i pieczątki',
'journey.studio.lockupGroup.chapters': 'Rozdziały',
'journey.studio.lockupGroup.quotes': 'Cytaty, podpisy i miejsca',
'journey.studio.lockup.poster-stack': 'Plakatowy stos',
'journey.studio.lockup.spaced-date': 'Linia daty i tytuł',
'journey.studio.lockup.serif-sun': 'Tytuł szeryfowy ze słońcem',
'journey.studio.lockup.title-script-year': 'Tytuł z linią kursywą',
'journey.studio.lockup.script-overlay': 'Słowo nad tytułem',
'journey.studio.lockup.photo-word': 'Zdjęcie w literach',
'journey.studio.lockup.fabric-band': 'Tytuł na wstędze',
'journey.studio.lockup.arched-title': 'Tytuł w łuku',
'journey.studio.lockup.retro-shadow': 'Cień w stylu retro',
'journey.studio.lockup.outline-echo': 'Echo konturów',
'journey.studio.lockup.minimal-caps': 'Rozstrzelone wersaliki',
'journey.studio.lockup.circle-badge': 'Okrągła odznaka',
'journey.studio.lockup.postmark': 'Stempel pocztowy',
'journey.studio.lockup.stamp': 'Pieczątka',
'journey.studio.lockup.ticket': 'Bilet',
'journey.studio.lockup.chapter-outline': 'Liczba w konturze',
'journey.studio.lockup.chapter-rules': 'Liczba między liniami',
'journey.studio.lockup.day-opener': 'Otwarcie dnia',
'journey.studio.lockup.quote-classic': 'Klasyczny cytat',
'journey.studio.lockup.quote-band': 'Cytat na paskach',
'journey.studio.lockup.caption-rule': 'Podpis z linią',
'journey.studio.lockup.caption-number': 'Numerowany podpis',
'journey.studio.lockup.coords-pin': 'Współrzędne z pinezką',
'journey.studio.lockup.coords-stack': 'Miejsce i współrzędne',
'journey.studio.lockupWord.title': 'Lizbona',
'journey.studio.lockupWord.subtitle': 'Lato nad morzem',
'journey.studio.lockupWord.kicker': 'Dziennik podróży',
'journey.studio.lockupWord.overlay': 'podróż',
'journey.studio.lockupWord.place': 'Portugalia',
'journey.studio.lockupWord.dates': 'Od 12 do 26 sierpnia',
'journey.studio.lockupWord.quote': 'Najpiękniejsze widoki przychodzą po najtrudniejszych podejściach.',
'journey.studio.lockupWord.caption': 'Alfama o świcie',
'journey.studio.lockupWord.label': 'Dzień',
};
export default studioText;
+2
View File
@@ -57,5 +57,7 @@ const assistantBook: TranslationStrings = {
'assistant.card.book.finding.font_mix': 'Слишком много шрифтов',
'assistant.card.book.finding.palette_drift': 'Цвета, которые не сочетаются',
'assistant.card.book.finding.repeat_layout': 'Снова и снова один и тот же макет',
'assistant.card.book.finding.curve_overflow': 'Изогнутый текст выходит за свою дугу',
'assistant.card.book.finding.effect_heavy': 'Слишком толстый контур',
};
export default assistantBook;
+3
View File
@@ -66,6 +66,8 @@ import studioPhoto from './studioPhoto';
import studioStickers from './studioStickers';
import studioShapes from './studioShapes';
import studioThemes from './studioThemes';
import studioStickers from './studioStickers';
import studioText from './studioText';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -127,6 +129,7 @@ const locale = {
...studioStickers,
...studioPhoto,
...studioShapes,
...studioText,
...oauth,
...system_notice,
...transport,
+79
View File
@@ -0,0 +1,79 @@
import type { TranslationStrings } from '../types';
const studioText: TranslationStrings = {
'journey.studio.fx.placement': 'Регистр и положение',
'journey.studio.fx.case': 'Регистр',
'journey.studio.fx.caseNone': 'Как введено',
'journey.studio.fx.caseUpper': 'Прописные',
'journey.studio.fx.caseLower': 'Строчные',
'journey.studio.fx.caseSmall': 'Капитель',
'journey.studio.fx.vertical': 'В рамке',
'journey.studio.fx.top': 'Сверху',
'journey.studio.fx.middle': 'По центру',
'journey.studio.fx.bottom': 'Снизу',
'journey.studio.fx.fit': 'Вписать в рамку',
'journey.studio.fx.fitHint':
'Заданный размер является наибольшим. Текст уменьшается, когда рамка становится слишком маленькой, и снова растёт, когда есть место.',
'journey.studio.fx.effects': 'Эффекты',
'journey.studio.fx.shadow': 'Жёсткая тень',
'journey.studio.fx.across': 'По горизонтали',
'journey.studio.fx.down': 'По вертикали',
'journey.studio.fx.band': 'Полоса маркера',
'journey.studio.fx.padAcross': 'Отступ по бокам',
'journey.studio.fx.padDown': 'Отступ сверху и снизу',
'journey.studio.fx.bandDrawn': 'Изогнутый текст и текст с фото рисуются без полосы маркера.',
'journey.studio.fx.curve': 'Изгиб',
'journey.studio.fx.straight': 'Прямо',
'journey.studio.fx.arc': 'Дуга',
'journey.studio.fx.circle': 'Круг',
'journey.studio.fx.bend': 'Кривизна',
'journey.studio.fx.start': 'Центр на',
'journey.studio.fx.letters': 'Буквы',
'journey.studio.fx.hollow': 'Полые',
'journey.studio.fx.noPhotos': 'В этом путешествии пока нет фото, которыми можно заполнить буквы.',
'journey.studio.fx.pickPhoto': 'Фото внутри букв',
'journey.studio.fx.focalHint': 'Перетащите, чтобы выбрать, какая часть фото видна сквозь буквы.',
'journey.studio.fx.zoom': 'Масштаб',
'journey.studio.fx.drawnHint':
'Изогнутый текст и текст с фото не переносятся: дуга вмещает одну строку, заливка фото начинает новую строку там, где вы нажмёте Enter.',
'journey.studio.textStylesHint':
'Каждый стиль набирается названием вашей книги и подстраивается под него. Он появляется выделенным как единый набор, чтобы его можно было сразу переместить; щёлкните по отдельной части, чтобы изменить её слова.',
'journey.studio.lockupGroup.titles': 'Заголовки',
'journey.studio.lockupGroup.badges': 'Значки и штампы',
'journey.studio.lockupGroup.chapters': 'Главы',
'journey.studio.lockupGroup.quotes': 'Цитаты, подписи и места',
'journey.studio.lockup.poster-stack': 'Плакатный столбец',
'journey.studio.lockup.spaced-date': 'Строка даты и заголовок',
'journey.studio.lockup.serif-sun': 'Антиквенный заголовок с солнцем',
'journey.studio.lockup.title-script-year': 'Заголовок с курсивной строкой',
'journey.studio.lockup.script-overlay': 'Слово поверх заголовка',
'journey.studio.lockup.photo-word': 'Фото в буквах',
'journey.studio.lockup.fabric-band': 'Заголовок на ленте',
'journey.studio.lockup.arched-title': 'Заголовок аркой',
'journey.studio.lockup.retro-shadow': 'Ретро-тень',
'journey.studio.lockup.outline-echo': 'Эхо контуров',
'journey.studio.lockup.minimal-caps': 'Разрядка прописными',
'journey.studio.lockup.circle-badge': 'Круглый значок',
'journey.studio.lockup.postmark': 'Почтовый штемпель',
'journey.studio.lockup.stamp': 'Штамп',
'journey.studio.lockup.ticket': 'Билет',
'journey.studio.lockup.chapter-outline': 'Контурная цифра',
'journey.studio.lockup.chapter-rules': 'Цифра между линейками',
'journey.studio.lockup.day-opener': 'Начало дня',
'journey.studio.lockup.quote-classic': 'Классическая цитата',
'journey.studio.lockup.quote-band': 'Цитата на полосах',
'journey.studio.lockup.caption-rule': 'Подпись с линейкой',
'journey.studio.lockup.caption-number': 'Нумерованная подпись',
'journey.studio.lockup.coords-pin': 'Координаты с булавкой',
'journey.studio.lockup.coords-stack': 'Место и координаты',
'journey.studio.lockupWord.title': 'Лиссабон',
'journey.studio.lockupWord.subtitle': 'Лето у моря',
'journey.studio.lockupWord.kicker': 'Путевой дневник',
'journey.studio.lockupWord.overlay': 'путешествие',
'journey.studio.lockupWord.place': 'Португалия',
'journey.studio.lockupWord.dates': 'С 12 по 26 августа',
'journey.studio.lockupWord.quote': 'Самые красивые виды открываются после самых трудных подъёмов.',
'journey.studio.lockupWord.caption': 'Алфама в первом свете',
'journey.studio.lockupWord.label': 'День',
};
export default studioText;

Some files were not shown because too many files have changed in this diff Show More