From 09ec8f5352a42e7165b83f12da8c5fc847862270 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 20:57:00 +0200 Subject: [PATCH] feat(studio): photo look with border, hard shadow, zoom, mirror, adjustments, duotone and cutout Photos get a look beyond crop, mask and frame style, in Studio and for the assistant alike: - border (width and colour, following the mask or the corner radius), a hard offset shadow and a die-cut cutout edge, all flat vector shapes in print - zoom 1 to 4 about the focal point and a left to right mirror - brightness, contrast, saturation and warmth, and a duotone; these are filters, labelled raster in print in the inspector - the print resolution of the selected photo in the inspector, with a warning under 200 dpi, measured from the original PhotoView moves out of SpreadView into its own file and uses per instance SVG ids, so a spread drawn twice keeps its clips and filters apart. The style operation takes the look, describe-spread lists it, the dpi and crop findings account for zoom and mirror, and a new raster_effect finding notes filters on a large photo. BOOK_SCHEMA_REV goes to 2. --- client/scripts/eslint-baseline.json | 2 +- client/scripts/rtl-baseline.json | 3 +- client/scripts/theme-baseline.json | 3 +- client/scripts/theme-disable-baseline.json | 3 +- .../Assistant/cards/book/bookCardModel.ts | 1 + client/src/components/Studio/PhotoView.tsx | 429 ++++++++++++++++++ client/src/components/Studio/SpreadView.tsx | 240 +--------- .../src/components/Studio/StudioInspector.tsx | 4 + .../Studio/photo/StudioPhotoLook.test.tsx | 159 +++++++ .../Studio/photo/StudioPhotoLook.tsx | 292 ++++++++++++ .../components/Studio/photo/StudioSlider.tsx | 61 +++ .../components/Studio/photo/photoLook.test.ts | 98 ++++ .../src/components/Studio/photo/photoLook.ts | 120 +++++ .../Studio/photo/usePhotoPixels.test.ts | 68 +++ .../components/Studio/photo/usePhotoPixels.ts | 40 ++ client/src/styles/studio-photo.css | 130 ++++++ .../unit/studio/photoLookInspector.test.tsx | 82 ++++ client/tests/unit/studio/photoView.test.tsx | 168 +++++++ .../tests/unit/studio/useBookStore.test.tsx | 7 +- server/src/nest/journey/journey-book.mcp.ts | 2 +- server/tests/e2e/journey-book.e2e.test.ts | 38 +- .../unit/nest/journey-book.service.test.ts | 6 +- .../journey-book-design.service.test.ts | 9 +- shared/src/book/book-element-ops.schema.ts | 4 +- shared/src/book/book-findings-design.ts | 7 +- shared/src/book/book-findings-photo.ts | 38 ++ shared/src/book/book-findings.ts | 6 +- shared/src/book/book-ops-elements.ts | 7 + shared/src/book/book-ops.schema.ts | 1 + shared/src/book/book-photo-look.schema.ts | 104 +++++ shared/src/book/book-photo-look.spec.ts | 247 ++++++++++ shared/src/book/book-photo-look.ts | 138 ++++++ shared/src/book/book-schema-rev.spec.ts | 26 +- shared/src/book/book-schema-rev.ts | 7 +- shared/src/book/book.schema.ts | 4 + shared/src/book/describe-spread.ts | 2 + shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/ar/studioPhoto.ts | 29 ++ shared/src/i18n/az/index.ts | 2 + shared/src/i18n/az/studioPhoto.ts | 29 ++ shared/src/i18n/br/index.ts | 2 + shared/src/i18n/br/studioPhoto.ts | 30 ++ shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/ca/studioPhoto.ts | 30 ++ shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/cs/studioPhoto.ts | 29 ++ shared/src/i18n/de/index.ts | 2 + shared/src/i18n/de/studioPhoto.ts | 29 ++ shared/src/i18n/en/index.ts | 2 + shared/src/i18n/en/studioPhoto.ts | 29 ++ shared/src/i18n/es/index.ts | 2 + shared/src/i18n/es/studioPhoto.ts | 30 ++ shared/src/i18n/et/index.ts | 2 + shared/src/i18n/et/studioPhoto.ts | 29 ++ shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/fr/studioPhoto.ts | 29 ++ shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/gr/studioPhoto.ts | 29 ++ shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/hu/studioPhoto.ts | 29 ++ shared/src/i18n/id/index.ts | 2 + shared/src/i18n/id/studioPhoto.ts | 29 ++ shared/src/i18n/it/index.ts | 2 + shared/src/i18n/it/studioPhoto.ts | 30 ++ shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ja/studioPhoto.ts | 29 ++ shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/ko/studioPhoto.ts | 29 ++ shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/nl/studioPhoto.ts | 29 ++ shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/pl/studioPhoto.ts | 29 ++ shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/ru/studioPhoto.ts | 29 ++ shared/src/i18n/sk/index.ts | 2 + shared/src/i18n/sk/studioPhoto.ts | 29 ++ shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/sv/studioPhoto.ts | 29 ++ shared/src/i18n/th/index.ts | 2 + shared/src/i18n/th/studioPhoto.ts | 29 ++ shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/tr/studioPhoto.ts | 29 ++ shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/uk/studioPhoto.ts | 29 ++ shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/vi/studioPhoto.ts | 29 ++ shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh-TW/studioPhoto.ts | 29 ++ shared/src/i18n/zh/index.ts | 2 + shared/src/i18n/zh/studioPhoto.ts | 29 ++ shared/src/index.ts | 2 + 91 files changed, 3130 insertions(+), 269 deletions(-) create mode 100644 client/src/components/Studio/PhotoView.tsx create mode 100644 client/src/components/Studio/photo/StudioPhotoLook.test.tsx create mode 100644 client/src/components/Studio/photo/StudioPhotoLook.tsx create mode 100644 client/src/components/Studio/photo/StudioSlider.tsx create mode 100644 client/src/components/Studio/photo/photoLook.test.ts create mode 100644 client/src/components/Studio/photo/photoLook.ts create mode 100644 client/src/components/Studio/photo/usePhotoPixels.test.ts create mode 100644 client/src/components/Studio/photo/usePhotoPixels.ts create mode 100644 client/src/styles/studio-photo.css create mode 100644 client/tests/unit/studio/photoLookInspector.test.tsx create mode 100644 client/tests/unit/studio/photoView.test.tsx create mode 100644 shared/src/book/book-findings-photo.ts create mode 100644 shared/src/book/book-photo-look.schema.ts create mode 100644 shared/src/book/book-photo-look.spec.ts create mode 100644 shared/src/book/book-photo-look.ts create mode 100644 shared/src/i18n/ar/studioPhoto.ts create mode 100644 shared/src/i18n/az/studioPhoto.ts create mode 100644 shared/src/i18n/br/studioPhoto.ts create mode 100644 shared/src/i18n/ca/studioPhoto.ts create mode 100644 shared/src/i18n/cs/studioPhoto.ts create mode 100644 shared/src/i18n/de/studioPhoto.ts create mode 100644 shared/src/i18n/en/studioPhoto.ts create mode 100644 shared/src/i18n/es/studioPhoto.ts create mode 100644 shared/src/i18n/et/studioPhoto.ts create mode 100644 shared/src/i18n/fr/studioPhoto.ts create mode 100644 shared/src/i18n/gr/studioPhoto.ts create mode 100644 shared/src/i18n/hu/studioPhoto.ts create mode 100644 shared/src/i18n/id/studioPhoto.ts create mode 100644 shared/src/i18n/it/studioPhoto.ts create mode 100644 shared/src/i18n/ja/studioPhoto.ts create mode 100644 shared/src/i18n/ko/studioPhoto.ts create mode 100644 shared/src/i18n/nl/studioPhoto.ts create mode 100644 shared/src/i18n/pl/studioPhoto.ts create mode 100644 shared/src/i18n/ru/studioPhoto.ts create mode 100644 shared/src/i18n/sk/studioPhoto.ts create mode 100644 shared/src/i18n/sv/studioPhoto.ts create mode 100644 shared/src/i18n/th/studioPhoto.ts create mode 100644 shared/src/i18n/tr/studioPhoto.ts create mode 100644 shared/src/i18n/uk/studioPhoto.ts create mode 100644 shared/src/i18n/vi/studioPhoto.ts create mode 100644 shared/src/i18n/zh-TW/studioPhoto.ts create mode 100644 shared/src/i18n/zh/studioPhoto.ts diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index d1308f2c7..b8ad7e455 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,6 +1,6 @@ { "src": { - "@typescript-eslint/no-non-null-assertion": 404, + "@typescript-eslint/no-non-null-assertion": 402, "@typescript-eslint/no-misused-promises": 854, "no-console": 88, "complexity": 226, diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index 1718ad049..4eef2f7f7 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -52,7 +52,8 @@ "components/Studio/BookSheetsView.tsx": 3, "components/Studio/PageNumbers.tsx": 1, "components/Studio/PeerCursors.tsx": 1, - "components/Studio/SpreadView.tsx": 8, + "components/Studio/PhotoView.tsx": 4, + "components/Studio/SpreadView.tsx": 4, "components/Studio/StudioCanvas.tsx": 22, "components/Studio/StudioElementsPanel.tsx": 1, "components/Studio/StudioExport.tsx": 1, diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 74cf5cc22..c1567128c 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -100,7 +100,8 @@ "components/Roadtrip/serviceMarker.ts": 1, "components/Settings/useAppearanceEditor.ts": 5, "components/Studio/BookSheetsView.tsx": 2, - "components/Studio/SpreadView.tsx": 3, + "components/Studio/PhotoView.tsx": 2, + "components/Studio/SpreadView.tsx": 1, "components/Studio/StudioElementsPanel.tsx": 8, "components/Studio/StudioInspector.tsx": 2, "components/Studio/StudioShell.tsx": 2, diff --git a/client/scripts/theme-disable-baseline.json b/client/scripts/theme-disable-baseline.json index 091ada772..0e1a34d3f 100644 --- a/client/scripts/theme-disable-baseline.json +++ b/client/scripts/theme-disable-baseline.json @@ -40,7 +40,8 @@ "components/Settings/AppearanceSettingsTab.tsx": 1, "components/Settings/PasskeysSection.tsx": 1, "components/Studio/BookSheetsView.tsx": 1, - "components/Studio/SpreadView.tsx": 2, + "components/Studio/PhotoView.tsx": 1, + "components/Studio/SpreadView.tsx": 1, "components/Studio/StudioElementsPanel.tsx": 2, "components/Studio/StudioSwatches.tsx": 1, "components/Studio/TravelElements.tsx": 6, diff --git a/client/src/components/Assistant/cards/book/bookCardModel.ts b/client/src/components/Assistant/cards/book/bookCardModel.ts index f1a5cd433..1fa2d3d83 100644 --- a/client/src/components/Assistant/cards/book/bookCardModel.ts +++ b/client/src/components/Assistant/cards/book/bookCardModel.ts @@ -57,6 +57,7 @@ const FINDING_LOOK: Readonly> = { font_mix: { labelKey: 'assistant.card.book.finding.font_mix' }, palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' }, repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' }, + raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' }, }; /** A page as the Studio names it: the cover, the first page, double page 3. */ diff --git a/client/src/components/Studio/PhotoView.tsx b/client/src/components/Studio/PhotoView.tsx new file mode 100644 index 000000000..8ee1fc457 --- /dev/null +++ b/client/src/components/Studio/PhotoView.tsx @@ -0,0 +1,429 @@ +import { photoLookOf, type BookFrame, type BookPhotoElement, type BookShapeId, type PhotoLook } from '@trek/shared'; +import { useId, type CSSProperties, type ReactNode } from 'react'; + +import { fontStack } from './bookFonts'; +import { photoSrc } from './bookRender'; +import { pictureFilter, silhouetteShadows, svgFilterMatrices, svgId, zoomPlacement } from './photo/photoLook'; +import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'; + +/** + * A picture on the page: possibly cut to a shape, possibly sitting in + * something, possibly wearing a look (book-photo-look.schema.ts in shared). + * + * Split out of SpreadView.tsx, which draws every kind and had no room left + * for the look. It is still the one renderer: the editor, the page + * thumbnails and the print path all draw a photo through this. + * + * The mask and the decoration are independent on purpose: a heart in a Polaroid + * is a silly thing to want and there is no reason to stop anyone wanting it, and + * keeping them separate means neither has to know about the other. + * + * ── The look, and how each part prints ─────────────────────────────────── + * + * Border, hard shadow and cutout are flat shapes of one colour, so they stay + * vector in the PDF. On a rectangular frame (no mask, or a card such as a + * Polaroid around it) the cutout and the shadow are box shadows without blur, + * which follow the corner radius; on a frame cut to a shape they are that + * shape's path, drawn behind the picture. The border is drawn over the edge of + * the picture, inside the frame, so the frame stays the box the fold and trim + * checks measure. Zoom and mirror move the picture inside its clip and change + * nothing around it. Adjustments, warmth and a duotone are filters and print + * as a raster, which the inspector says. + * + * Every SVG id is per instance (useId): the page view draws each spread twice, + * and two clip paths with one id cut the second picture with the first one's + * shape. + */ + +/** Two decimals of a millimetre, matching what the document itself stores. */ +const round2 = (n: number) => Math.round(n * 100) / 100; + +const PHOTO_FILTERS: Record = { + none: undefined, + bw: 'grayscale(1) contrast(1.05)', + warm: 'saturate(1.1) sepia(0.16)', + cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)', + // Faded, the way a print left in the light goes: lifted blacks, less colour. + fade: 'saturate(0.72) contrast(0.88) brightness(1.08)', + contrast: 'contrast(1.22) saturate(1.06)', +}; + +/** + * How much of the frame the decoration around a picture eats, as a fraction of + * the shorter side. + * + * A Polaroid's chin is the reason this is a table rather than one number: the + * bottom border is roughly three times the others, and getting that ratio wrong + * is the difference between a Polaroid and a photo with a white edge. + */ +const FRAME_INSET: Record = { + none: { pad: 0, bottom: 0 }, + polaroid: { pad: 0.055, bottom: 0.17 }, + white: { pad: 0.035, bottom: 0.035 }, + shadow: { pad: 0, bottom: 0 }, + film: { pad: 0.075, bottom: 0.075 }, + tape: { pad: 0, bottom: 0 }, +}; + +/** The frame styles that draw a card around the picture, which makes the frame's outline a rectangle. */ +const CARDS = new Set(['polaroid', 'white', 'film']); + +/** The cutout and the hard shadow of a frame cut to a shape: its path, grown and offset, behind the picture. */ +function ShapeSilhouette({ frame, mask, look }: { frame: BookFrame; mask: BookShapeId; look: PhotoLook }) { + const { w, h } = frame; + const d = scalePath(SHAPE_PATHS[mask], w, h); + const rule = HOLED_SHAPES.has(mask) ? 'evenodd' : undefined; + const grow = look.cutout?.width ?? 0; + const path = (color: string, dx = 0, dy = 0) => ( + + ); + return ( + + {look.shadow && {path(look.shadow.color, look.shadow.dx, look.shadow.dy)}} + {look.cutout && path(look.cutout.color)} + + ); +} + +/** The border: a band along the inside of the picture's edge, following its shape. */ +function BorderBand({ + mask, + radius, + look, + width, + height, +}: { + mask: BookShapeId | null; + radius: number; + look: PhotoLook; + width: number; + height: number; +}) { + const border = look.border; + if (!border || width <= 0 || height <= 0) return null; + if (mask && mask !== 'rect') { + // Stroked twice as wide as the band and cut by the picture's own clip, so + // exactly the inner half shows and the band follows every curve. + return ( + + + + ); + } + return ( +
+ ); +} + +/** The picture itself, zoomed about its focal point and mirrored where the look says so. */ +function Picture({ + el, + photoId, + look, + big, + print, + filterId, +}: { + el: BookPhotoElement; + photoId: number; + look: PhotoLook; + big: boolean; + print: boolean; + filterId: string | null; +}) { + const zoom = zoomPlacement(look.zoom, el.focalX, el.focalY); + const img = ( + + ); + if (!look.flipX) return img; + // The picture is mirrored inside a box the size of the frame, so the part + // the crop shows stays the same part and the mask is not mirrored with it. + return
{img}
; +} + +/** What an empty frame shows while editing: a hatch, and where there is room, the instruction. */ +function Hatch({ clipped, label, labelSize }: { clipped: boolean; label: ReactNode; labelSize: number }) { + return ( +
+ {label && ( + + {label} + + )} +
+ ); +} + +export function PhotoView({ + el, + big, + print, + dropLabel, + frameStyle, +}: { + el: BookPhotoElement; + big: boolean; + print: boolean; + dropLabel: string; + /** The positioned box every element sits in, from SpreadView. */ + frameStyle: CSSProperties; +}) { + const uid = useId(); + const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none; + const side = Math.min(el.frame.w, el.frame.h); + // Rounded to a hundredth of a millimetre, the same precision the document + // stores. Without it a fraction of the frame lands in the CSS as + // `10.200000000000001mm`, which is not wrong but is float noise in a + // stylesheet that a person may well end up reading. + const pad = round2(deco.pad * side); + const bottom = round2(deco.bottom * side); + const clipId = svgId('c', uid); + // The shape the picture is cut to; a rect mask is no mask at all. + const mask = el.mask && el.mask !== 'rect' ? el.mask : null; + const clipped = mask !== null; + // An empty frame wears the frame style but none of the look: a border or a + // shadow around a hatch would read as part of the picture to come. + const empty = el.photoId == null; + const look = photoLookOf(empty ? {} : el); + const matrices = svgFilterMatrices(look); + const filterId = matrices.length ? svgId('f', uid) : null; + const card = CARDS.has(el.frameStyle); + + /* + * An empty frame is a template's promise: it says where a picture goes before + * anyone has chosen which, and it says it *in the frame it will wear*. + * + * This used to render on its own, ignoring frameStyle entirely, so a Polaroid + * placeholder was an ordinary dashed rectangle until a photograph landed in + * it and the chin appeared from nowhere. The two branches have been folded + * together: the surround is drawn once, and only what goes inside it differs. + */ + + // In the printed book an unfilled frame is nothing at all: a hatch and an + // instruction on a page someone paid to have bound would be a defect. + if (empty && print) return null; + + // The label is sized in millimetres so it scales with the zoom exactly as the + // page does, and it steps aside on a frame too small to hold it. + const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085)); + const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34; + + // A photograph lying on a page, rather than printed into it. Soft and low: a + // hard shadow reads as a UI card, not as paper. The look's own cutout and + // hard shadow join it when the frame's outline is a rectangle. + const soft = el.frameStyle === 'shadow' || el.frameStyle === 'polaroid' ? '0 1.2mm 3mm rgba(0,0,0,.22)' : null; + const boxShadows = [ + ...(clipped && !card ? [] : silhouetteShadows(look.shadow, look.cutout)), + ...(soft ? [soft] : []), + ]; + + const picture = ( +
+ {el.photoId == null ? ( + + ) : ( + + )} + {!empty && ( + + )} +
+ ); + + return ( +
+ {mask && !card && !empty && (look.shadow || look.cutout) && ( + + )} + + {(mask || filterId) && ( + + + {mask && ( + + + + )} + {filterId && ( + + {matrices.map((values, i) => ( + + ))} + + )} + + + )} + + {picture} + + {/* Sprocket holes. Drawn as a repeating gradient rather than as elements + so the count follows the width instead of being fixed at a size that + only looks right on one frame. */} + {el.frameStyle === 'film' && + [0, 1].map((row) => ( +
+ ))} + + {/* + Two strips, one over each top corner, each centred *on* its corner so it + overhangs the picture evenly on both sides, which is what a piece of + tape does. Offsetting by a fraction of the strip instead left them + sitting beside the corners rather than across them. + */} + {el.frameStyle === 'tape' && + ([-1, 1] as const).map((dir) => { + const tapeW = side * 0.26; + const tapeH = side * 0.075; + return ( +
+ ); + })} +
+ ); +} diff --git a/client/src/components/Studio/SpreadView.tsx b/client/src/components/Studio/SpreadView.tsx index 132323891..8bee40292 100644 --- a/client/src/components/Studio/SpreadView.tsx +++ b/client/src/components/Studio/SpreadView.tsx @@ -1,11 +1,11 @@ import type { CSSProperties } from 'react' import type { - BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread, + BookElement, BookIconElement, BookPageSetup, BookShapeElement, BookSpread, } from '@trek/shared' -import { photoSrc } from './bookRender' import { fontStack } from './bookFonts' import { iconComponent } from './iconLibrary' -import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes' +import { PhotoView } from './PhotoView' +import { HOLED_SHAPES, SHAPE_PATHS, scalePath } from './shapes' import { TravelElementView } from './TravelElements' import { PageNumbers } from './PageNumbers' @@ -34,42 +34,12 @@ function frameStyle(el: BookElement): CSSProperties { } } -/** Two decimals of a millimetre, matching what the document itself stores. */ -const round2 = (n: number) => Math.round(n * 100) / 100 - /** #rrggbb plus an alpha, for the fades a cover panel needs. */ function hexToRgba(hex: string, alpha: number): string { const n = Number.parseInt(hex.slice(1), 16) return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})` } -const FILTERS: Record = { - none: undefined, - bw: 'grayscale(1) contrast(1.05)', - warm: 'saturate(1.1) sepia(0.16)', - cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)', - // Faded, the way a print left in the light goes: lifted blacks, less colour. - fade: 'saturate(0.72) contrast(0.88) brightness(1.08)', - contrast: 'contrast(1.22) saturate(1.06)', -} - -/** - * How much of the frame the decoration around a picture eats, as a fraction of - * the shorter side. - * - * A Polaroid's chin is the reason this is a table rather than one number: the - * bottom border is roughly three times the others, and getting that ratio wrong - * is the difference between a Polaroid and a photo with a white edge. - */ -const FRAME_INSET: Record = { - none: { pad: 0, bottom: 0 }, - polaroid: { pad: 0.055, bottom: 0.17 }, - white: { pad: 0.035, bottom: 0.035 }, - shadow: { pad: 0, bottom: 0 }, - film: { pad: 0.075, bottom: 0.075 }, - tape: { pad: 0, bottom: 0 }, -} - /** * A shape, drawn. * @@ -153,206 +123,6 @@ function ShapeView({ el }: { el: BookShapeElement }) { ) } -/** - * A picture — possibly cut to a shape, possibly sitting in something. - * - * The mask and the decoration are independent on purpose: a heart in a Polaroid - * is a silly thing to want and there is no reason to stop anyone wanting it, and - * keeping them separate means neither has to know about the other. - */ -function PhotoView({ el, big, print, dropLabel }: { - el: BookPhotoElement; big: boolean; print: boolean; dropLabel: string -}) { - const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none - const side = Math.min(el.frame.w, el.frame.h) - // Rounded to a hundredth of a millimetre — the same precision the document - // stores. Without it a fraction of the frame lands in the CSS as - // `10.200000000000001mm`, which is not wrong but is float noise in a - // stylesheet that a person may well end up reading. - const pad = round2(deco.pad * side) - const bottom = round2(deco.bottom * side) - const clipId = `c-${el.id}` - const clipped = el.mask && el.mask !== 'rect' - - /* - * An empty frame is a template's promise: it says where a picture goes before - * anyone has chosen which — and it says it *in the frame it will wear*. - * - * This used to render on its own, ignoring frameStyle entirely, so a Polaroid - * placeholder was an ordinary dashed rectangle until a photograph landed in - * it and the chin appeared from nowhere. The two branches have been folded - * together: the surround is drawn once, and only what goes inside it differs. - */ - const empty = el.photoId == null - - // In the printed book an unfilled frame is nothing at all — a hatch and an - // instruction on a page someone paid to have bound would be a defect. - if (empty && print) return null - - // The label is sized in millimetres so it scales with the zoom exactly as the - // page does, and it steps aside on a frame too small to hold it. - const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085)) - const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34 - - const hatch = ( -
- {roomy && dropLabel && ( - - {dropLabel} - - )} -
- ) - - const picture = ( -
- {empty ? hatch : ( - - )} -
- ) - - return ( -
- {clipped && ( - - - - - - - - )} - - {picture} - - {/* Sprocket holes. Drawn as a repeating gradient rather than as elements - so the count follows the width instead of being fixed at a size that - only looks right on one frame. */} - {el.frameStyle === 'film' && [0, 1].map(row => ( -
- ))} - - {/* Two strips of tape, held at an angle across opposite corners. */} - {/* - Two strips, one over each top corner, each centred *on* its corner so it - overhangs the picture evenly on both sides — which is what a piece of - tape does. Offsetting by a fraction of the strip instead left them - sitting beside the corners rather than across them. - */} - {el.frameStyle === 'tape' && ([-1, 1] as const).map(dir => { - const tapeW = side * 0.26 - const tapeH = side * 0.075 - return ( -
- ) - })} -
- ) -} - /** * A lucide drawing, at the size the frame gives it. * @@ -389,9 +159,9 @@ export function ElementView({ // A photo left out is drawn as its empty frame, kept even in print, so the // page still shows where a picture sits without showing the picture. if (el.photoId != null && hiddenPhotoIds?.has(el.photoId)) { - return + return } - return + return } if (el.kind === 'shape') return diff --git a/client/src/components/Studio/StudioInspector.tsx b/client/src/components/Studio/StudioInspector.tsx index e88e52093..efe7ded95 100644 --- a/client/src/components/Studio/StudioInspector.tsx +++ b/client/src/components/Studio/StudioInspector.tsx @@ -14,6 +14,7 @@ import { formatBookCoords } from '@trek/shared' import { iconComponent, iconLabel, searchIcons } from './iconLibrary' import { Choice, Line, NumField, Section, Switch } from './StudioControls' import { FontButtons, WeightLine } from './StudioTypeControls' +import { PhotoCropExtras, PhotoLookSections } from './photo/StudioPhotoLook' import type { JourneySource } from './StudioSidebar' import { Tooltip } from '../shared/Tooltip' @@ -412,6 +413,7 @@ export function StudioInspector({ ) : (

{t('journey.studio.emptyFrame')}

)} +
@@ -458,6 +460,8 @@ export function StudioInspector({ )}
+ +
+ +
+ ); +} + +/** A border or a cutout: on or off, how deep, what colour. */ +function EdgeRow({ + label, + value, + start, + onSet, + t, +}: { + label: string; + value: BookPhotoEdge | null; + start: BookPhotoEdge; + onSet: (edge: BookPhotoEdge | undefined) => void; + t: T; +}) { + return ( +
+ onSet(value ? undefined : { ...start })} /> + {value && ( +
+ onSet({ ...value, width })} + /> + + onSet({ ...value, color })} /> + +
+ )} +
+ ); +} + +/** The hard shadow: on or off, how far it falls, how strong, what colour. */ +function ShadowRow({ el, set, live, t }: RowProps) { + const shadow = photoLookOf(el).shadow; + return ( +
+ set({ shadow: shadow ? undefined : { ...PHOTO_LOOK_STARTS.shadow } })} + /> + {shadow && ( +
+
+ {(['dx', 'dy'] as const).map((axis) => ( + set({ shadow: { ...shadow, [axis]: v } })} + /> + ))} +
+ live({ shadow: { ...shadow, alpha } })} + onCommit={(alpha) => set({ shadow: { ...shadow, alpha } })} + /> + + set({ shadow: { ...shadow, color } })} /> + +
+ )} +
+ ); +} + +/** Brightness, contrast, saturation and warmth, each from -100 to +100, and one press back to the picture as shot. */ +function AdjustRows({ el, set, live, t }: RowProps) { + const adjust = photoLookOf(el).adjust; + const next = (key: PhotoAdjustment, v: number): BookPhotoAdjust | undefined => { + const all = { ...(adjust ?? {}), [key]: Math.round(v * 100) / 100 }; + return PHOTO_ADJUSTMENTS.some((k) => all[k]) ? all : undefined; + }; + return ( + <> + {PHOTO_ADJUSTMENTS.map((key) => ( + live({ adjust: next(key, v) })} + onCommit={(v) => set({ adjust: next(key, v) })} + /> + ))} + {adjust && ( + + )} + + ); +} + +/** The duotone: on or off, one of the ready pairs, or the two inks picked by hand. */ +function DuotoneRows({ el, set, t }: Omit) { + const duotone = photoLookOf(el).duotone; + return ( + <> + set({ duotone: duotone ? undefined : { ...PHOTO_LOOK_STARTS.duotone } })} + /> + {duotone && ( +
+
+ {PHOTO_DUOTONES.map((pair) => { + const on = pair.dark === duotone.dark && pair.light === duotone.light; + return ( +
+ + set({ duotone: { ...duotone, dark } })} /> + + + set({ duotone: { ...duotone, light } })} /> + +
+ )} + + ); +} + +/** The look's own sections, after the inspector's Look section. */ +export function PhotoLookSections(props: RowProps) { + const { el, set, t } = props; + const look = photoLookOf(el); + const raster = t('journey.studio.photo.rasterHint'); + return ( + <> +
+ set({ border })} + t={t} + /> + + set({ cutout })} + t={t} + /> +
+
+ +
+
+ +
+ + ); +} diff --git a/client/src/components/Studio/photo/StudioSlider.tsx b/client/src/components/Studio/photo/StudioSlider.tsx new file mode 100644 index 000000000..26e0fbedd --- /dev/null +++ b/client/src/components/Studio/photo/StudioSlider.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; + +/** + * A number set by dragging, for the values a person judges by eye: a zoom, a + * brightness, a shadow's strength. + * + * The page follows the thumb while it moves (`onLive`, no undo step), and the + * value is committed once, when the thumb is let go or the keys are released + * (`onCommit`, one undo step), the way the focal point pad works. + */ +export function StudioSlider({ + label, + value, + min, + max, + step, + format, + onLive, + onCommit, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + /** What the value reads as beside the track. */ + format: (v: number) => string; + onLive: (v: number) => void; + onCommit: (v: number) => void; +}) { + const [draft, setDraft] = useState(null); + const shown = draft ?? value; + const end = () => { + if (draft === null) return; + setDraft(null); + onCommit(draft); + }; + + return ( + + ); +} diff --git a/client/src/components/Studio/photo/photoLook.test.ts b/client/src/components/Studio/photo/photoLook.test.ts new file mode 100644 index 000000000..817b20ca2 --- /dev/null +++ b/client/src/components/Studio/photo/photoLook.test.ts @@ -0,0 +1,98 @@ +import { photoLookOf, type PhotoLook } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; + +import { + adjustFilter, + duotoneMatrix, + pictureFilter, + rgba, + silhouetteShadows, + svgFilterMatrices, + svgId, + warmthMatrix, + zoomPlacement, +} from './photoLook'; + +/** + * The numbers the photo look prints with: the CSS filter chain, the colour + * matrices of the SVG filter, the box shadows of a rectangular frame and the + * placement of a zoomed picture. + */ + +const look = (over: Partial = {}): PhotoLook => ({ ...photoLookOf({}), ...over }); +const adjust = (a: Partial>) => ({ + brightness: 0, + contrast: 0, + saturation: 0, + warmth: 0, + ...a, +}); + +describe('photo look rendering', () => { + it('FE-STUDIO-LOOK-001: turns adjustments into filter functions, leaving out what does not move', () => { + expect(adjustFilter(null)).toEqual([]); + expect(adjustFilter(adjust({ brightness: 0.2, contrast: -1, saturation: -1 }))).toEqual([ + 'brightness(1.1)', + 'contrast(0.5)', + 'saturate(0)', + ]); + expect(adjustFilter(adjust({ warmth: 0.5 }))).toEqual([]); + }); + + it('FE-STUDIO-LOOK-002: chains the preset, the adjustments and the SVG filter, and draws nothing for none', () => { + expect(pictureFilter(undefined, look(), null)).toBeUndefined(); + expect(pictureFilter('grayscale(1)', look({ adjust: adjust({ contrast: 0.4 }) }), 'f-x')).toBe( + 'grayscale(1) contrast(1.2) url(#f-x)' + ); + }); + + it('FE-STUDIO-LOOK-003: warms by raising red and lowering blue, and cools the other way', () => { + expect(warmthMatrix(0)).toBeNull(); + expect(warmthMatrix(1)).toBe('1.12 0 0 0 0 0 1.03 0 0 0 0 0 0.88 0 0 0 0 0 1 0'); + expect(warmthMatrix(-1)).toBe('0.88 0 0 0 0 0 0.97 0 0 0 0 0 1.12 0 0 0 0 0 1 0'); + }); + + it('FE-STUDIO-LOOK-004: maps black to the dark ink and white to the light one', () => { + const values = duotoneMatrix({ dark: '#000000', light: '#ffffff' }).split(' ').map(Number); + // Each colour row reads the luminance weights and starts from the dark ink. + expect(values.slice(0, 5)).toEqual([0.213, 0.715, 0.072, 0, 0]); + expect(values.slice(15)).toEqual([0, 0, 0, 1, 0]); + const navy = duotoneMatrix({ dark: '#ff0000', light: '#ff0000' }).split(' ').map(Number); + // The same ink at both ends is that ink, whatever the picture. + expect(navy.slice(0, 5)).toEqual([0, 0, 0, 0, 1]); + expect(navy.slice(5, 10)).toEqual([0, 0, 0, 0, 0]); + }); + + it('FE-STUDIO-LOOK-005: needs an SVG filter only for warmth or a duotone, warmth first', () => { + expect(svgFilterMatrices(look())).toEqual([]); + expect(svgFilterMatrices(look({ adjust: adjust({ brightness: 0.3 }) }))).toEqual([]); + const both = svgFilterMatrices( + look({ adjust: adjust({ warmth: 1 }), duotone: { dark: '#000000', light: '#ffffff' } }) + ); + expect(both).toHaveLength(2); + expect(both[0]).toBe(warmthMatrix(1)); + }); + + it('FE-STUDIO-LOOK-006: draws the cutout and a shadow grown by it as flat box shadows', () => { + expect(silhouetteShadows(null, null)).toEqual([]); + expect(silhouetteShadows({ dx: 2, dy: 3, color: '#102030', alpha: 0.5 }, null)).toEqual([ + '2mm 3mm 0 0mm rgba(16, 32, 48, 0.5)', + ]); + expect(silhouetteShadows({ dx: -1, dy: 1, color: '#000000', alpha: 1 }, { width: 3, color: '#ffffff' })).toEqual([ + '0 0 0 3mm #ffffff', + '-1mm 1mm 0 3mm rgba(0, 0, 0, 1)', + ]); + expect(rgba('#ff8000', 0.25)).toBe('rgba(255, 128, 0, 0.25)'); + }); + + it('FE-STUDIO-LOOK-007: places a zoomed picture so the focal point stays where it was', () => { + expect(zoomPlacement(1, 0.5, 0.5)).toBeNull(); + expect(zoomPlacement(2, 0.5, 0.5)).toEqual({ size: '200%', transform: 'translate(-25%, -25%)' }); + expect(zoomPlacement(4, 0, 1)).toEqual({ size: '400%', transform: 'translate(0%, -75%)' }); + }); + + it('FE-STUDIO-LOOK-008: makes an id a url() can reference from what useId returns', () => { + expect(svgId('c', ':r1:')).toBe('c-r1'); + expect(svgId('f', '«r2»')).toBe('f-r2'); + }); +}); diff --git a/client/src/components/Studio/photo/photoLook.ts b/client/src/components/Studio/photo/photoLook.ts new file mode 100644 index 000000000..44291657b --- /dev/null +++ b/client/src/components/Studio/photo/photoLook.ts @@ -0,0 +1,120 @@ +import type { BookPhotoDuotone, BookPhotoEdge, BookPhotoShadow, PhotoLook } from '@trek/shared'; + +/** + * The photo look (book-photo-look.schema.ts in shared) turned into what the + * page draws: CSS for the picture, the colour matrices of its SVG filter, and + * the box shadows a rectangular frame wears. + * + * Pure, and apart from PhotoView, because these are the numbers the look + * prints with and a rendered component is an awkward place to prove them. + * + * What prints as vector and what does not is decided here: a border, a hard + * shadow and a cutout are boxes and paths of one colour, while adjustments, + * warmth and a duotone are filters on the picture, which the printer receives + * as a raster the way it already received the filter presets. + */ + +/** #rrggbb with an alpha, for a shadow drawn at its own strength. */ +export function rgba(hex: string, alpha: number): string { + const n = Number.parseInt(hex.slice(1), 16); + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +} + +const round3 = (v: number) => Math.round(v * 1000) / 1000; + +/** + * Brightness, contrast and saturation as CSS filter functions, after the + * preset filter. Half a step either way for the first two, which keeps the + * ends of the slider usable; saturation runs from grey to twice the colour. + */ +export function adjustFilter(adjust: PhotoLook['adjust']): string[] { + if (!adjust) return []; + const out: string[] = []; + if (adjust.brightness) out.push(`brightness(${round3(1 + adjust.brightness * 0.5)})`); + if (adjust.contrast) out.push(`contrast(${round3(1 + adjust.contrast * 0.5)})`); + if (adjust.saturation) out.push(`saturate(${round3(1 + adjust.saturation)})`); + return out; +} + +/** The filter on the picture: the preset, the adjustments, then the SVG filter when there is one. */ +export function pictureFilter( + preset: string | undefined, + look: PhotoLook, + svgFilterId: string | null +): string | undefined { + const parts = [preset, ...adjustFilter(look.adjust), svgFilterId ? `url(#${svgFilterId})` : undefined].filter( + (p): p is string => !!p + ); + return parts.length ? parts.join(' ') : undefined; +} + +/** + * Warmth as a colour matrix: red up and blue down for warm, the reverse for + * cool, green a little with red so a warm picture does not turn pink. + */ +export function warmthMatrix(warmth: number): string | null { + if (!warmth) return null; + const r = round3(1 + warmth * 0.12); + const g = round3(1 + warmth * 0.03); + const b = round3(1 - warmth * 0.12); + return `${r} 0 0 0 0 0 ${g} 0 0 0 0 0 ${b} 0 0 0 0 0 1 0`; +} + +const channels = (hex: string) => { + const n = Number.parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => c / 255) as [number, number, number]; +}; + +/** Rec. 709 luminance weights, the brightness the duotone maps from. */ +const LUMA = [0.2126, 0.7152, 0.0722] as const; + +/** A duotone as a colour matrix: the picture's luminance mapped from `dark` (black) to `light` (white). */ +export function duotoneMatrix(duotone: BookPhotoDuotone): string { + const dark = channels(duotone.dark); + const light = channels(duotone.light); + const rows = dark.map((d, i) => { + const span = (light[i] ?? 0) - d; + return `${LUMA.map((w) => round3(w * span)).join(' ')} 0 ${round3(d)}`; + }); + return `${rows.join(' ')} 0 0 0 1 0`; +} + +/** The colour matrices the picture's SVG filter applies, in order; empty when it needs none. */ +export function svgFilterMatrices(look: PhotoLook): string[] { + const warm = warmthMatrix(look.adjust?.warmth ?? 0); + return [warm, look.duotone ? duotoneMatrix(look.duotone) : null].filter((m): m is string => m !== null); +} + +/** + * The die-cut edge and the hard shadow of a rectangular frame, as box + * shadows: no blur, so each is one flat shape, drawn behind the frame and + * following its corner radius. The shadow is grown by the cutout too, so it + * falls from the whole sticker rather than from the picture inside it. + */ +export function silhouetteShadows(shadow: BookPhotoShadow | null, cutout: BookPhotoEdge | null): string[] { + const out: string[] = []; + const grow = cutout?.width ?? 0; + if (cutout) out.push(`0 0 0 ${cutout.width}mm ${cutout.color}`); + if (shadow) out.push(`${shadow.dx}mm ${shadow.dy}mm 0 ${grow}mm ${rgba(shadow.color, shadow.alpha)}`); + return out; +} + +/** + * Where the picture sits inside a zoomed frame, as the image's own size and a + * shift in its own percentages: z times the frame, moved so the focal point + * stays where the frame put it at zoom 1. + */ +export function zoomPlacement( + zoom: number, + focalX: number, + focalY: number +): { size: string; transform: string } | null { + if (zoom <= 1) return null; + const shift = (f: number) => round3((-(zoom - 1) * f * 100) / zoom); + return { size: `${round3(zoom * 100)}%`, transform: `translate(${shift(focalX)}%, ${shift(focalY)}%)` }; +} + +/** A safe SVG id from React's useId, which carries characters a url() reference would need escaped. */ +export function svgId(prefix: string, reactId: string): string { + return `${prefix}-${reactId.replace(/[^A-Za-z0-9_-]/g, '')}`; +} diff --git a/client/src/components/Studio/photo/usePhotoPixels.test.ts b/client/src/components/Studio/photo/usePhotoPixels.test.ts new file mode 100644 index 000000000..ef0aef083 --- /dev/null +++ b/client/src/components/Studio/photo/usePhotoPixels.test.ts @@ -0,0 +1,68 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { usePhotoPixels } from './usePhotoPixels'; + +/** + * The original's pixel size, read off the original itself for the photo the + * inspector shows: nothing until it has loaded, nothing for another photo + * than the one asked about, nothing for an empty frame. + */ + +class FakeImage { + static made: FakeImage[] = []; + onload: (() => void) | null = null; + naturalWidth = 0; + naturalHeight = 0; + src = ''; + constructor() { + FakeImage.made.push(this); + } + load(width: number, height: number) { + this.naturalWidth = width; + this.naturalHeight = height; + this.onload?.(); + } +} + +beforeEach(() => { + FakeImage.made = []; + vi.stubGlobal('Image', FakeImage); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('usePhotoPixels', () => { + it('FE-STUDIO-PIXELS-001: loads the original and answers its size once it is in', () => { + const { result } = renderHook(() => usePhotoPixels(7)); + expect(result.current).toBeNull(); + const img = FakeImage.made[0] as FakeImage; + expect(img.src).toBe('/api/photos/7/original'); + act(() => img.load(4000, 3000)); + expect(result.current).toEqual({ width: 4000, height: 3000 }); + }); + + it('FE-STUDIO-PIXELS-002: forgets the size when another photo is asked about, and ignores the old one landing late', () => { + const { result, rerender } = renderHook(({ id }: { id: number | null }) => usePhotoPixels(id), { + initialProps: { id: 7 as number | null }, + }); + const first = FakeImage.made[0] as FakeImage; + rerender({ id: 8 }); + expect(first.onload).toBeNull(); + act(() => first.load(100, 100)); + expect(result.current).toBeNull(); + act(() => (FakeImage.made[1] as FakeImage).load(600, 400)); + expect(result.current).toEqual({ width: 600, height: 400 }); + rerender({ id: null }); + expect(result.current).toBeNull(); + expect(FakeImage.made).toHaveLength(2); + }); + + it('FE-STUDIO-PIXELS-003: says nothing for a picture that loads without a size', () => { + const { result } = renderHook(() => usePhotoPixels(9)); + act(() => (FakeImage.made[0] as FakeImage).load(0, 0)); + expect(result.current).toBeNull(); + }); +}); diff --git a/client/src/components/Studio/photo/usePhotoPixels.ts b/client/src/components/Studio/photo/usePhotoPixels.ts new file mode 100644 index 000000000..ce2a133c5 --- /dev/null +++ b/client/src/components/Studio/photo/usePhotoPixels.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; + +import { photoSrc } from '../bookRender'; + +export interface PhotoPixels { + width: number; + height: number; +} + +/** + * The pixel size of a photo's original, measured by loading it. + * + * The journey the Studio holds knows which pictures there are but not how + * large their originals are (the server measures that for the book engine and + * keeps it there), and a print resolution is nothing without it. So the + * inspector reads it off the original itself, for the one photo selected: the + * canvas loads that same original from a zoom of a third up, so most of the + * time the browser already has it. Null until it has loaded, and for an empty + * frame or a picture that does not load, which says nothing rather than guess. + */ +export function usePhotoPixels(photoId: number | null): PhotoPixels | null { + const [seen, setSeen] = useState<{ photoId: number; pixels: PhotoPixels } | null>(null); + + useEffect(() => { + if (photoId == null) return undefined; + let cancelled = false; + const img = new Image(); + img.onload = () => { + if (cancelled || !img.naturalWidth || !img.naturalHeight) return; + setSeen({ photoId, pixels: { width: img.naturalWidth, height: img.naturalHeight } }); + }; + img.src = photoSrc(photoId, true); + return () => { + cancelled = true; + img.onload = null; + }; + }, [photoId]); + + return seen && seen.photoId === photoId ? seen.pixels : null; +} diff --git a/client/src/styles/studio-photo.css b/client/src/styles/studio-photo.css new file mode 100644 index 000000000..0c1a340f4 --- /dev/null +++ b/client/src/styles/studio-photo.css @@ -0,0 +1,130 @@ +/* + * The photo look in the Studio inspector: the zoom and mirror row beside the + * crop, the print resolution, the border, shadow and cutout parts, the + * adjustment sliders and the duotone pairs. Everything reads the inspector's + * own tokens from studio.css, so it follows the user's scheme and accent. + */ + +/* ----------------- a slider with its value ----------------- */ +.trek-dash .st-slider { + display: grid; + grid-template-columns: 64px minmax(0, 1fr) 40px; + align-items: center; + gap: 8px; + min-height: 28px; +} +.trek-dash .st-slider + .st-slider { + margin-top: 4px; +} +.trek-dash .st-slider-label { + font-size: 11px; + color: var(--ink-3); + line-height: 1.25; +} +.trek-dash .st-slider input[type='range'] { + width: 100%; + min-width: 0; + accent-color: var(--accent); + cursor: pointer; +} +.trek-dash .st-slider-value { + font-size: 11px; + color: var(--ink-2); + text-align: end; + font-variant-numeric: tabular-nums; +} + +/* ----------------- beside the crop ----------------- */ +.trek-dash .st-photo-crop { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 10px; +} +.trek-dash .st-photo-crop > .st-slider { + width: 100%; +} + +/* The print resolution: quiet when it is fine, a warning tint when it is not. */ +.trek-dash .st-photo-dpi { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + padding: 8px 10px; + border-radius: 10px; + background: color-mix(in oklch, var(--ink) 4%, transparent); + color: var(--ink-2); +} +.trek-dash .st-photo-dpi svg { + flex: none; + margin-top: 1px; +} +.trek-dash .st-photo-dpi.is-low { + background: var(--warning-soft); + color: var(--warning); +} +.trek-dash .st-photo-dpi-value { + font-size: 12px; + font-weight: 500; + line-height: 1.4; +} +.trek-dash .st-photo-dpi-hint { + margin-top: 2px; + font-size: 11.5px; + line-height: 1.5; + color: var(--ink-2); +} + +/* ----------------- one part of the look: a switch and what it opens ----------------- */ +.trek-dash .st-photo-part + .st-photo-part { + border-top: 1px solid var(--line); + margin-top: 4px; + padding-top: 4px; +} +.trek-dash .st-photo-part-body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 6px 0 8px; +} +.trek-dash .st-photo-part-body .st-line + .st-line { + margin-top: 0; +} + +.trek-dash .st-photo-reset { + margin-top: 10px; +} + +/* ----------------- duotone pairs ----------------- */ +.trek-dash .st-photo-duos { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.trek-dash .st-photo-duo { + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid var(--line-2); + box-shadow: var(--sh-xs); + cursor: pointer; + transition: + transform 0.14s, + box-shadow 0.14s; +} +.trek-dash .st-photo-duo:hover { + transform: scale(1.06); +} +.trek-dash .st-photo-duo.is-on { + box-shadow: 0 0 0 2px var(--accent); +} +.trek-dash .st-photo-duo:focus { + outline: none; +} +.trek-dash .st-photo-duo:focus-visible { + box-shadow: + 0 0 0 2px var(--accent-soft), + 0 0 0 3px var(--accent); +} diff --git a/client/tests/unit/studio/photoLookInspector.test.tsx b/client/tests/unit/studio/photoLookInspector.test.tsx new file mode 100644 index 000000000..04696c158 --- /dev/null +++ b/client/tests/unit/studio/photoLookInspector.test.tsx @@ -0,0 +1,82 @@ +import type { BookDocument, BookElement, BookPhotoElement } from '@trek/shared'; +import { PHOTO_LOOK_STARTS, bookPageSetupSchema, bookPhotoElementSchema } from '@trek/shared'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { StudioInspector } from '../../../src/components/Studio/StudioInspector'; +import type { JourneySource } from '../../../src/components/Studio/StudioSidebar'; +import { useStudioStore } from '../../../src/store/studioStore'; +import { fireEvent, render, screen } from '../../helpers/render'; + +/** + * The photo look through the inspector itself, written into the book the + * store holds: the rows sit where the panel puts them and every one of them + * is one undo step. + */ + +const source: JourneySource = { entries: [], photos: [], photoEntries: {} }; +const page = bookPageSetupSchema.parse({}); + +function load(over: Record = {}) { + const el = bookPhotoElementSchema.parse({ + id: 'p1', + kind: 'photo', + frame: { x: 20, y: 30, w: 60, h: 40 }, + photoId: 12, + ...over, + }) as BookElement; + useStudioStore.getState().load({ + version: 1, + title: 'T', + page, + spreads: [{ id: 'sp1', role: 'inner', background: null, elements: [el], parked: [], entryId: null }], + } as BookDocument); + useStudioStore.getState().select(['p1']); +} + +const photo = () => useStudioStore.getState().doc?.spreads[0]?.elements[0] as BookPhotoElement; + +function draw() { + return render( + {}} + t={(k: string) => k} + locale="en" + /> + ); +} + +beforeEach(() => load()); + +describe('the photo look in the inspector', () => { + it('FE-STUDIO-LOOKINSP-001: zooms and mirrors from the crop section', () => { + draw(); + const zoom = screen.getByRole('slider', { name: /journey.studio.photo.zoom/ }); + fireEvent.change(zoom, { target: { value: '2' } }); + fireEvent.pointerUp(zoom); + fireEvent.click(screen.getByRole('button', { name: /journey.studio.photo.flip/ })); + expect(photo()).toMatchObject({ zoom: 2, flipX: true }); + }); + + it('FE-STUDIO-LOOKINSP-002: puts a border on and takes it off again, leaving nothing behind', () => { + draw(); + fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.edges' })); + fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.border', pressed: false })); + expect(photo().border).toEqual(PHOTO_LOOK_STARTS.border); + fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.border', pressed: true })); + expect(JSON.parse(JSON.stringify(photo()))).not.toHaveProperty('border'); + }); + + it('FE-STUDIO-LOOKINSP-003: each look change is one undo step', () => { + draw(); + fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.duotone', expanded: false })); + fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.duotone', pressed: false })); + expect(photo().duotone).toEqual(PHOTO_LOOK_STARTS.duotone); + useStudioStore.getState().undo(); + expect(photo().duotone).toBeUndefined(); + }); +}); diff --git a/client/tests/unit/studio/photoView.test.tsx b/client/tests/unit/studio/photoView.test.tsx new file mode 100644 index 000000000..c62c4471a --- /dev/null +++ b/client/tests/unit/studio/photoView.test.tsx @@ -0,0 +1,168 @@ +import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'; +import { bookPageSetupSchema } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; + +import { SpreadView } from '../../../src/components/Studio/SpreadView'; +import { render } from '../../helpers/render'; + +/** + * The photo look as the page draws it, through SpreadView, the one renderer + * the editor, the thumbnails and the print path share: border, hard shadow, + * cutout, zoom, mirror, adjustments and duotone, and ids that stay unique + * when a spread is drawn twice. + */ + +const page: BookPageSetup = bookPageSetupSchema.parse({ + preset: 'square-210', + pageWidth: 210, + pageHeight: 210, + bleed: 3, + safe: 5, +}); + +const frame = { x: 10, y: 20, w: 80, h: 60 }; + +const photo = (over: Record = {}): BookElement => + ({ + frame, + rotation: 0, + opacity: 1, + locked: false, + id: 'p1', + kind: 'photo', + photoId: 42, + fit: 'cover', + focalX: 0.5, + focalY: 0.5, + radius: 0, + filter: 'none', + mask: null, + frameStyle: 'none', + ...over, + }) as BookElement; + +const spread = (elements: BookElement[]): BookSpread => ({ + id: 's1', + role: 'inner', + background: null, + elements, + parked: [], + entryId: null, +}); + +const draw = (elements: BookElement[], print = false) => + render(); + +/** The photo's own box: the sheet's first child. */ +const outer = (container: HTMLElement) => container.firstElementChild?.firstElementChild as HTMLElement; +const styleOf = (el: Element | null | undefined) => el?.getAttribute('style') ?? ''; + +describe('the photo look on the page', () => { + it('FE-STUDIO-PHOTO-001: draws a plain photo exactly as before, with no look layers', () => { + const { container } = draw([photo()]); + expect(outer(container).querySelector('svg')).toBeNull(); + const img = container.querySelector('img') as HTMLImageElement; + expect(img.style.width).toBe('100%'); + expect(img.style.transform).toBe(''); + expect(styleOf(outer(container))).not.toContain('box-shadow'); + }); + + it('FE-STUDIO-PHOTO-002: lays a border band over the edge of a rectangular picture, rounded with it', () => { + const { container } = draw([photo({ border: { width: 4, color: '#ffffff' }, radius: 3 })]); + const band = container.querySelector('img')?.parentElement?.querySelector('div[aria-hidden]') as HTMLElement; + expect(band.style.borderWidth).toBe('4mm'); + expect(band.style.borderStyle).toBe('solid'); + expect(band.style.borderRadius).toBe('3mm'); + }); + + it('FE-STUDIO-PHOTO-003: strokes the border along a mask, twice as wide so its inner half shows', () => { + const { container } = draw([photo({ mask: 'heart', border: { width: 2.5, color: '#102030' } })]); + const stroked = [...container.querySelectorAll('path')].find((p) => p.getAttribute('fill') === 'none'); + expect(stroked?.getAttribute('stroke')).toBe('#102030'); + expect(stroked?.getAttribute('stroke-width')).toBe('5'); + }); + + it('FE-STUDIO-PHOTO-004: gives a rectangular frame its cutout and hard shadow as flat box shadows', () => { + const { container } = draw([ + photo({ shadow: { dx: 2, dy: 3, color: '#000000', alpha: 0.5 }, cutout: { width: 3, color: '#ffffff' } }), + ]); + const css = styleOf(outer(container)); + expect(css).toContain('0 0 0 3mm'); + expect(css).toContain('2mm 3mm 0 3mm'); + expect(outer(container).querySelector('svg')).toBeNull(); + }); + + it('FE-STUDIO-PHOTO-005: draws the cutout and shadow of a shaped frame as its path, behind the picture', () => { + const { container } = draw([ + photo({ + mask: 'star-5', + shadow: { dx: 1, dy: 1, color: '#222222', alpha: 0.4 }, + cutout: { width: 2, color: '#fafafa' }, + }), + ]); + const box = outer(container); + const silhouette = box.firstElementChild as SVGElement; + expect(silhouette.tagName.toLowerCase()).toBe('svg'); + const paths = silhouette.querySelectorAll('path'); + expect(paths).toHaveLength(2); + expect(paths[0]?.getAttribute('transform')).toBe('translate(1 1)'); + expect(paths[0]?.parentElement?.getAttribute('opacity')).toBe('0.4'); + expect(paths[1]?.getAttribute('stroke-width')).toBe('4'); + expect(styleOf(box)).not.toContain('box-shadow'); + }); + + it('FE-STUDIO-PHOTO-006: a Polaroid keeps a rectangular shadow even with a mask, beside its soft one', () => { + const { container } = draw([ + photo({ frameStyle: 'polaroid', mask: 'heart', shadow: { dx: 2, dy: 2, color: '#000000', alpha: 1 } }), + ]); + const css = styleOf(outer(container)); + expect(css).toContain('2mm 2mm 0 0mm'); + expect(css).toContain('1.2mm 3mm'); + }); + + it('FE-STUDIO-PHOTO-007: zooms the picture about its focal point and mirrors it inside its frame', () => { + const { container } = draw([photo({ zoom: 2, focalX: 0.5, focalY: 0.5, flipX: true })]); + const img = container.querySelector('img') as HTMLImageElement; + expect(img.style.width).toBe('200%'); + expect(img.style.transform).toBe('translate(-25%, -25%)'); + expect(img.parentElement?.style.transform).toBe('scaleX(-1)'); + }); + + it('FE-STUDIO-PHOTO-008: filters the picture with the adjustments and an SVG filter for warmth and duotone', () => { + const { container } = draw([ + photo({ + filter: 'bw', + adjust: { brightness: 0.2, warmth: 0.5 }, + duotone: { dark: '#000000', light: '#ffffff' }, + }), + ]); + const filter = container.querySelector('filter') as SVGFilterElement; + expect(filter.querySelectorAll('feColorMatrix')).toHaveLength(2); + const css = styleOf(container.querySelector('img')); + expect(css).toContain('grayscale(1)'); + expect(css).toContain('brightness(1.1)'); + expect(css).toContain(`url(#${filter.id})`); + }); + + it('FE-STUDIO-PHOTO-009: gives every drawing of a spread its own clip and filter ids', () => { + const el = photo({ mask: 'heart', duotone: { dark: '#000000', light: '#ffffff' } }); + const { container } = render( + <> + + + + ); + const ids = [...container.querySelectorAll('clipPath, filter')].map((n) => n.id); + expect(ids).toHaveLength(4); + expect(new Set(ids).size).toBe(4); + }); + + it('FE-STUDIO-PHOTO-010: an empty frame wears no look, and prints nothing', () => { + const look = { border: { width: 4, color: '#ffffff' }, cutout: { width: 2, color: '#ffffff' }, mask: 'heart' }; + const { container } = draw([photo({ photoId: null, ...look })]); + expect(container.querySelector('img')).toBeNull(); + expect([...container.querySelectorAll('path')].some((p) => p.getAttribute('fill') === 'none')).toBe(false); + const { container: printed } = draw([photo({ photoId: null, ...look })], true); + expect(printed.querySelector('[style*="left: 10mm"]')).toBeNull(); + }); +}); diff --git a/client/tests/unit/studio/useBookStore.test.tsx b/client/tests/unit/studio/useBookStore.test.tsx index bb6d4947b..4211b81ab 100644 --- a/client/tests/unit/studio/useBookStore.test.tsx +++ b/client/tests/unit/studio/useBookStore.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { act, renderHook } from '@testing-library/react' import type { BookDocument, BookRecord } from '@trek/shared' +import { BOOK_SCHEMA_REV } from '@trek/shared' /** * Autosaving a Studio book (#1973). @@ -851,13 +852,13 @@ describe('a book a newer Studio saved', () => { expect(result.current.state.status).toBe('newer') }) - /* A book of today goes out exactly as it went out before the revision existed. */ - it('FE-STUDIO-REV-006: sends a book of today without a revision in it', async () => { + /* A book goes out stamped with the revision this build writes, so an older Studio opens it read-only. */ + it('FE-STUDIO-REV-006: sends a book stamped with the revision this build writes', async () => { const { result } = await mount() act(() => { result.current.queueSave(doc('a'), 'T') }) await tick() - expect(api.saveBook.mock.calls[0][1].document).not.toHaveProperty('schemaRev') + expect(api.saveBook.mock.calls[0][1].document.schemaRev).toBe(BOOK_SCHEMA_REV) }) }) diff --git a/server/src/nest/journey/journey-book.mcp.ts b/server/src/nest/journey/journey-book.mcp.ts index 61a370766..bf1af526e 100644 --- a/server/src/nest/journey/journey-book.mcp.ts +++ b/server/src/nest/journey/journey-book.mcp.ts @@ -177,7 +177,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 (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; 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, fonts, strokes and the like), 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) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. 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 (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; 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, fonts, strokes 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) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. 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, diff --git a/server/tests/e2e/journey-book.e2e.test.ts b/server/tests/e2e/journey-book.e2e.test.ts index 104343fe9..6ad870ea5 100644 --- a/server/tests/e2e/journey-book.e2e.test.ts +++ b/server/tests/e2e/journey-book.e2e.test.ts @@ -28,7 +28,7 @@ import { MikroORM } from '@mikro-orm/core'; import { Client } from '@modelcontextprotocol/sdk/client/index'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'; import type { INestApplication } from '@nestjs/common'; -import { bookWriteResultSchema } from '@trek/shared'; +import { BOOK_SCHEMA_REV, bookWriteResultSchema } from '@trek/shared'; import request from 'supertest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -209,7 +209,7 @@ describe('Studio book over MCP and REST', () => { .get(`/api/journeys/${journey.id}/book`) .set('Cookie', authCookie(owner.id)); const row = await findRow(orm, JourneyBooks, { journey: journey.id }); - const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: 2 }); + const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: BOOK_SCHEMA_REV + 1 }); await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer }); const saved = await request(app.getHttpServer()) @@ -220,7 +220,7 @@ describe('Studio book over MCP and REST', () => { expect(saved.body).toEqual({ error: 'Book was saved by a newer version of TREK Studio', code: 'book_schema_newer', - schemaRev: 2, + schemaRev: BOOK_SCHEMA_REV + 1, }); const tool = await callTool(owner.id, 'edit_book_page', { @@ -249,15 +249,43 @@ describe('Studio book over MCP and REST', () => { const saved = await request(app.getHttpServer()) .put(`/api/journeys/${journey.id}/book`) .set('Cookie', authCookie(owner.id)) - .send({ title: before.body.book.title, document: { ...document, schemaRev: 2 }, baseVersion: 1 }); + .send({ + title: before.body.book.title, + document: { ...document, schemaRev: BOOK_SCHEMA_REV + 1 }, + baseVersion: 1, + }); expect(saved.status).toBe(409); expect(saved.body).toEqual({ error: 'Book was saved by a newer version of TREK Studio', code: 'book_schema_newer', - schemaRev: 2, + schemaRev: BOOK_SCHEMA_REV + 1, }); expect(sent()).toEqual([]); const after = await findRow(orm, JourneyBooks, { journey: journey.id }); expect(after?.version).toBe(1); }); + + it('JBE2E-007: the photo look set over MCP is what GET reads next, stamped with the revision that wrote it', async () => { + const { owner, journey, inner } = await journeyWithBook(); + await callTool(owner.id, 'edit_book_page', { + journeyId: journey.id, + spreadId: inner, + ops: [{ op: 'add', element: { kind: 'photo' } }], + }); + const read = async () => + (await request(app.getHttpServer()).get(`/api/journeys/${journey.id}/book`).set('Cookie', authCookie(owner.id))) + .body.book.document as { schemaRev?: number; spreads: Array<{ elements: Array> }> }; + const frame = (await read()).spreads[2]?.elements.find((e) => e.kind === 'photo'); + const look = { border: { width: 5, color: '#ffffff' }, zoom: 1.5, flipX: true, adjust: { warmth: 0.4 } }; + + const styled = await callTool(owner.id, 'edit_book_page', { + journeyId: journey.id, + spreadId: inner, + ops: [{ op: 'style', elementId: frame?.id, set: { kind: 'photo', ...look } }], + }); + expect(bookWriteResultSchema.safeParse(styled).success).toBe(true); + const after = await read(); + expect(after.schemaRev).toBe(BOOK_SCHEMA_REV); + expect(after.spreads[2]?.elements.find((e) => e.id === frame?.id)).toMatchObject(look); + }); }); diff --git a/server/tests/unit/nest/journey-book.service.test.ts b/server/tests/unit/nest/journey-book.service.test.ts index 6f3af3e61..7824d1aba 100644 --- a/server/tests/unit/nest/journey-book.service.test.ts +++ b/server/tests/unit/nest/journey-book.service.test.ts @@ -31,7 +31,7 @@ import { import { createTestJourneyBooksRepo } from '../../helpers/journey-share-repos'; import { resetTestDb } from '../../helpers/test-db'; import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow'; -import { normalizeBookDocument } from '@trek/shared'; +import { BOOK_SCHEMA_REV, normalizeBookDocument } from '@trek/shared'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -697,7 +697,7 @@ describe('format revisions', () => { }); it('JBOOK-014: the book tools leave a book this server cannot read whole alone, before any edit runs', async () => { - const { user, journey } = await storedAt(2); + const { user, journey } = await storedAt(BOOK_SCHEMA_REV + 1); const rec = await recordSends(); try { const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null })); @@ -716,7 +716,7 @@ describe('format revisions', () => { const { user, journey } = await storedAt(1); const rec = await recordSends(); try { - const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: 2 }); + const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: BOOK_SCHEMA_REV + 1 }); const thrown = await catchDomainError(() => books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })), ); diff --git a/server/tests/unit/nest/journey/journey-book-design.service.test.ts b/server/tests/unit/nest/journey/journey-book-design.service.test.ts index 9fbaf1644..657989813 100644 --- a/server/tests/unit/nest/journey/journey-book-design.service.test.ts +++ b/server/tests/unit/nest/journey/journey-book-design.service.test.ts @@ -34,7 +34,12 @@ import { makeUser } from '../../../helpers/factories/users'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestModule, type TestModule } from '../../../helpers/test-module'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; -import { bookDesignAnswerSchema, bookDesignApplyResultSchema, type BookDesignAnswer } from '@trek/shared'; +import { + BOOK_SCHEMA_REV, + bookDesignAnswerSchema, + bookDesignApplyResultSchema, + type BookDesignAnswer, +} from '@trek/shared'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -327,7 +332,7 @@ describe('taking a design back', () => { const applied = await designs.apply(journey.id, owner.id, { designId: answer.design_id }); const revisionId = applied.revision_id ?? 0; const kept = await findRow(orm, JourneyBookRevisions, { id: revisionId }); - const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: 2 }); + const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: BOOK_SCHEMA_REV + 1 }); await updateRows(orm, JourneyBookRevisions, { id: revisionId }, { document: newer }); t.realtime.reset(); diff --git a/shared/src/book/book-element-ops.schema.ts b/shared/src/book/book-element-ops.schema.ts index 2140ff2a9..8593b3cd6 100644 --- a/shared/src/book/book-element-ops.schema.ts +++ b/shared/src/book/book-element-ops.schema.ts @@ -27,6 +27,7 @@ import { bookTypesetFontSchema, } from './book-op-enums'; import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids'; +import { BOOK_PHOTO_LOOK_PROPS } from './book-photo-look.schema'; import { MAX_BOOK_COUNTRIES, bookBadgeElementSchema, @@ -179,7 +180,8 @@ const METRICS = z.array(bookMetricSchema).min(1).max(7); * resolves them from the journey, exactly as the Travel panel does. */ export const bookElementPropsSchema = z.discriminatedUnion('kind', [ - z.strictObject({ kind: z.literal('photo'), ...PHOTO_PROPS }), + // The photo look only here, not on `add`: a new frame is styled after it exists, and the field list stays out of the add schema twice over. + z.strictObject({ kind: z.literal('photo'), ...PHOTO_PROPS, ...BOOK_PHOTO_LOOK_PROPS }), z.strictObject({ kind: z.literal('text'), ...TEXT_PROPS }), z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdSchema.optional(), ...SHAPE_PROPS }), z.strictObject({ kind: z.literal('icon'), ...ICON_PROPS }), diff --git a/shared/src/book/book-findings-design.ts b/shared/src/book/book-findings-design.ts index 21d3b47d1..1a2d1d82a 100644 --- a/shared/src/book/book-findings-design.ts +++ b/shared/src/book/book-findings-design.ts @@ -2,6 +2,7 @@ import { compareCodeUnits } from '../common/compare'; import { contrastOfLuminance, deltaE, greyLuminance, luminance, parseHex, requiredContrast } from './book-colour'; 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 type { BookDocument, BookElement, BookFrame, BookPageSetup, BookPhotoElement, BookSpread } from './book.schema'; /** @@ -40,7 +41,7 @@ const centreIn = (b: Box, px: number, py: number) => px >= b.x && px <= b.x + b. */ export function bookPhotoCrop(el: BookPhotoElement, info: BookPhotoInfo | undefined): Box | null { if (!info?.width || !info.height) return null; - const f = el.frame; + const f = photoZoomBox(el.frame, photoZoomOf(el), el.focalX, el.focalY); const pick = el.fit === 'contain' ? Math.min : Math.max; const scale = pick(f.w / info.width, f.h / info.height); const w = info.width * scale; @@ -62,7 +63,9 @@ export function bookBusyUnder(el: BookPhotoElement, info: BookPhotoInfo | undefi const crop = bookPhotoCrop(el, info); const grid = info?.busy; if (!crop || !grid || grid.length !== 64) return null; - const onPhoto = overlap(box, el.frame); + // A mirrored picture shows under the box what lies under its mirror image. + const under = el.flipX ? { ...box, x: 2 * el.frame.x + el.frame.w - box.x - box.w } : box; + const onPhoto = overlap(under, el.frame); const area = onPhoto && overlap(onPhoto, crop); if (!area) return null; const cw = crop.w / 8; diff --git a/shared/src/book/book-findings-photo.ts b/shared/src/book/book-findings-photo.ts new file mode 100644 index 000000000..560a87576 --- /dev/null +++ b/shared/src/book/book-findings-photo.ts @@ -0,0 +1,38 @@ +import type { BookFinding } from './book-ops.schema'; +import { photoLookOf } from './book-photo-look'; +import type { BookPageSetup, BookSpread } from './book.schema'; + +/** + * What the photo look (book-photo-look.schema.ts) is worth knowing about on a + * proof. + * + * Adjustments and a duotone are drawn through filters, and a filtered picture + * goes to the printer as a raster the browser made rather than as the + * original's own pixels. On a small picture nobody sees the difference; on a + * picture a quarter of a page or larger it can print softer than the same + * picture without the filter, so the finding says so, as context. + */ + +/** The share of one page a filtered picture covers before the finding speaks. */ +const RASTER_EFFECT_SHARE = 0.25; + +export function photoLookChecks(spread: BookSpread, page: BookPageSetup): BookFinding[] { + const pageArea = page.pageWidth * page.pageHeight; + if (pageArea <= 0) return []; + const out: BookFinding[] = []; + for (const el of spread.elements) { + if (el.kind !== 'photo' || el.photoId == null) continue; + const look = photoLookOf(el); + const what = [look.adjust ? 'adjustments' : null, look.duotone ? 'a duotone' : null].filter(Boolean); + if (!what.length) continue; + const share = (el.frame.w * el.frame.h) / pageArea; + if (share < RASTER_EFFECT_SHARE) continue; + out.push({ + code: 'raster_effect', + severity: 'info', + elementIds: [el.id], + message: `photo ${el.id} carries ${what.join(' and ')} over ${Math.round(share * 100)}% of a page; filters print as a raster and can look softer than the original, drop them where the picture should stay sharp`, + }); + } + return out; +} diff --git a/shared/src/book/book-findings.ts b/shared/src/book/book-findings.ts index 049d04a1d..42dd40aaa 100644 --- a/shared/src/book/book-findings.ts +++ b/shared/src/book/book-findings.ts @@ -1,6 +1,8 @@ import { bookVisiblePart, sortBookFindings } from './book-findings-design'; +import { photoLookChecks } from './book-findings-photo'; import type { BookPhotoInfo } from './book-ops-core'; import type { BookFinding } from './book-ops.schema'; +import { photoZoomOf } from './book-photo-look'; import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema'; /** @@ -63,7 +65,8 @@ export function photoDpi(el: BookPhotoElement, info: BookPhotoInfo | undefined): el.fit === 'contain' ? Math.min(el.frame.w / info.width, el.frame.h / info.height) : Math.max(el.frame.w / info.width, el.frame.h / info.height); - return Math.round(25.4 / mmPerPx); + // A zoomed picture spreads each pixel over that many times the paper. + return Math.round(25.4 / (mmPerPx * photoZoomOf(el))); } /** Average letter width per em at weight 400, per family: measured on story text as the Studio sets each family. */ @@ -506,6 +509,7 @@ export function spreadFindings( emptyFrames(ctx); overflow(ctx); photoChecks(ctx); + ctx.out.push(...photoLookChecks(spread, page)); parked(ctx); return sortBookFindings(ctx.out); } diff --git a/shared/src/book/book-ops-elements.ts b/shared/src/book/book-ops-elements.ts index 39447300f..7f9fe1c47 100644 --- a/shared/src/book/book-ops-elements.ts +++ b/shared/src/book/book-ops-elements.ts @@ -13,6 +13,7 @@ import { unlockedElement, type BookOpsContext, } from './book-ops-core'; +import { BOOK_PHOTO_LOOK_KEYS } from './book-photo-look.schema'; import { bookBadgeValue, bookCountryMark, @@ -132,6 +133,7 @@ function statsOf(env: ElementOpEnv) { } /** The look fields a kind takes, from the props schema, so `add` and `style` set exactly what `style` may set. */ +const PHOTO_LOOK_KEYS = new Set(BOOK_PHOTO_LOOK_KEYS); const PROP_KEYS = new Map(bookElementPropsSchema.options.map((o) => [o.shape.kind.value, lookKeys(o.shape)])); function lookKeys(shape: object): string[] { @@ -145,6 +147,11 @@ function withProps(el: BookElement, props: Record): BookElement for (const key of keys) { const value = props[key]; if (value === undefined) continue; + // A photo look part set to null is turned off, which leaves it out of the document. + if (value === null && PHOTO_LOOK_KEYS.has(key)) { + delete next[key]; + continue; + } next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value; } return next as unknown as BookElement; diff --git a/shared/src/book/book-ops.schema.ts b/shared/src/book/book-ops.schema.ts index 96ceb6a5b..bea0a6a96 100644 --- a/shared/src/book/book-ops.schema.ts +++ b/shared/src/book/book-ops.schema.ts @@ -403,6 +403,7 @@ export const BOOK_FINDING_CODES = [ 'font_mix', 'palette_drift', 'repeat_layout', + 'raster_effect', ] as const; export type BookFindingCode = (typeof BOOK_FINDING_CODES)[number]; diff --git a/shared/src/book/book-photo-look.schema.ts b/shared/src/book/book-photo-look.schema.ts new file mode 100644 index 000000000..2b77d176a --- /dev/null +++ b/shared/src/book/book-photo-look.schema.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; + +/** + * The photo look: what a picture wears beyond its crop, mask and frame style. + * + * - `border`: a band of one colour along the inside of the frame, `width` + * millimetres deep, following the mask when the picture is cut to a shape + * (the 3 to 6 mm white border of a postcard print). + * - `shadow`: a hard copy of the picture's outline in one colour, offset by + * `dx`/`dy` millimetres and drawn behind it at `alpha`. No blur, so it prints + * as vector like everything else on the page. + * - `zoom`: 1 to 4 times the crop the fit gives, centred on the focal point, + * so the focal point keeps meaning what it meant. + * - `flipX`: the picture mirrored left to right inside its frame; the frame, + * the mask and the border stay as they are. + * - `adjust`: brightness, contrast, saturation and warmth, each -1 to 1 with 0 + * as shot. Drawn as filters, so the picture prints as a raster. + * - `duotone`: the picture's tones mapped from `dark` to `light`, also a + * filter and also raster in print. + * - `cutout`: a die-cut edge, the outline of the mask (or of the rounded + * rectangle) grown by `width` millimetres and filled behind the picture: the + * sticker look. + * + * Every field is optional and null means off, so a document from before them + * reads as it always did and an older reader that drops them still draws the + * picture. A value the reader cannot read falls back to off rather than + * failing the photo or its page. + */ + +const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb'); + +/** The zoom range, against the crop the fit gives on its own. */ +export const PHOTO_ZOOM = { min: 1, max: 4 } as const; +/** How deep a border or a cutout may be, in millimetres. */ +export const PHOTO_EDGE_MAX = 30; +/** How far a hard shadow may fall, in millimetres either way. */ +export const PHOTO_SHADOW_REACH = 30; + +type ObjectFactory = typeof z.object | typeof z.strictObject; + +/** + * The parts, built once for the document (plain objects, which drop a field + * they do not know, as the rest of the document does) and once for the + * operations (strict, as every operation is), so the two cannot drift. + */ +function lookSchemas(obj: ObjectFactory) { + const edge = obj({ width: z.number().min(0.1).max(PHOTO_EDGE_MAX), color: hex }); + const reach = z.number().finite().min(-PHOTO_SHADOW_REACH).max(PHOTO_SHADOW_REACH); + const signed = z.number().min(-1).max(1).optional(); + return { + edge, + shadow: obj({ dx: reach, dy: reach, color: hex, alpha: z.number().min(0).max(1) }), + zoom: z.number().min(PHOTO_ZOOM.min).max(PHOTO_ZOOM.max), + adjust: obj({ brightness: signed, contrast: signed, saturation: signed, warmth: signed }), + duotone: obj({ dark: hex, light: hex }), + }; +} + +const doc = lookSchemas(z.object); + +export const bookPhotoEdgeSchema = doc.edge; +export const bookPhotoShadowSchema = doc.shadow; +export const bookPhotoAdjustSchema = doc.adjust; +export const bookPhotoDuotoneSchema = doc.duotone; +export type BookPhotoEdge = z.infer; +export type BookPhotoShadow = z.infer; +export type BookPhotoAdjust = z.infer; +export type BookPhotoDuotone = z.infer; + +/** Optional, null for off, and off again when the stored value cannot be read. */ +const field = (schema: T) => schema.nullable().optional().catch(undefined); + +/** The fields a photo element carries, spread into its schema in book.schema.ts. */ +export const BOOK_PHOTO_LOOK_FIELDS = { + border: field(doc.edge), + shadow: field(doc.shadow), + zoom: doc.zoom.optional().catch(undefined), + flipX: z.boolean().optional().catch(undefined), + adjust: field(doc.adjust), + duotone: field(doc.duotone), + cutout: field(doc.edge), +}; + +/** The names of the look fields, for the operations that take any of them. */ +export const BOOK_PHOTO_LOOK_KEYS = Object.keys(BOOK_PHOTO_LOOK_FIELDS) as (keyof typeof BOOK_PHOTO_LOOK_FIELDS)[]; + +const ops = lookSchemas(z.strictObject); + +/** + * The look fields as the `style` operation takes them. Short descriptions on + * purpose: the page edit's tool schema is held under a size ceiling. + */ +export const BOOK_PHOTO_LOOK_PROPS = { + border: ops.edge.nullable().optional().describe('A band inside the frame edge, width mm; null removes it.'), + shadow: ops.shadow.nullable().optional().describe('A hard offset shadow, dx/dy mm, alpha 0..1; null removes it.'), + zoom: ops.zoom.optional().describe('1 to 4 times the crop, about the focal point.'), + flipX: z.boolean().optional().describe('Mirror the picture left to right.'), + adjust: ops.adjust + .nullable() + .optional() + .describe('Each -1..1, 0 or left out is as shot; prints as raster. null resets.'), + duotone: ops.duotone.nullable().optional().describe('Tones mapped from dark to light; prints as raster.'), + cutout: ops.edge.nullable().optional().describe('A die-cut edge grown width mm around the shape, behind the photo.'), +}; diff --git a/shared/src/book/book-photo-look.spec.ts b/shared/src/book/book-photo-look.spec.ts new file mode 100644 index 000000000..268a3b15c --- /dev/null +++ b/shared/src/book/book-photo-look.spec.ts @@ -0,0 +1,247 @@ +import { photoDpi, spreadFindings } from './book-findings'; +import { bookBusyUnder, bookPhotoCrop, bookVisiblePart } from './book-findings-design'; +import { photoLookChecks } from './book-findings-photo'; +import { normalizeBookDocument } from './book-normalize'; +import { applyBookOps } from './book-ops'; +import { bookPageOpSchema } from './book-ops.schema'; +import { blankBook } from './book-pages'; +import { + PHOTO_LOOK_STARTS, + photoAdjustOf, + photoLookIsRaster, + photoLookOf, + photoLookWords, + photoZoomBox, + photoZoomOf, +} from './book-photo-look'; +import { BOOK_PHOTO_LOOK_KEYS, PHOTO_ZOOM } from './book-photo-look.schema'; +import { + bookPageSetupSchema, + bookPhotoElementSchema, + type BookDocument, + type BookPhotoElement, + type BookSpread, +} from './book.schema'; +import { describeSpread } from './describe-spread'; + +import { describe, expect, it } from 'vitest'; + +/** + * The photo look: border, hard shadow, zoom, mirror, adjustments, duotone and + * cutout. How the document reads them (and what it does with one it cannot + * read), how the findings and the words for a model take them into account, + * and how the page edit sets and clears them. + */ + +const frame = { x: 230, y: 20, w: 80, h: 60 }; +const photo = (over: Record = {}): BookPhotoElement => + bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame, photoId: 1, ...over }); +const page = bookPageSetupSchema.parse({}); +const info = { photoId: 1, width: 4000, height: 3000 }; + +function book(elements: BookPhotoElement[]): BookDocument { + const doc = blankBook({ title: 'Iceland', 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 inner = (doc: BookDocument) => doc.spreads[2] as BookSpread; +const style = (doc: BookDocument, set: Record) => + applyBookOps( + doc, + { spreadId: 'sp-in', ops: [{ op: 'style', elementId: 'p1', set: { kind: 'photo', ...set } }] }, + { + newId: (p) => `${p}-n`, + photos: [info], + }, + ); + +describe('the photo look in the document', () => { + it('BKLOOK-001: a photo from before the look reads with every part off', () => { + const plain = photo(); + for (const key of BOOK_PHOTO_LOOK_KEYS) expect(plain[key]).toBeUndefined(); + expect(photoLookOf(plain)).toEqual({ + border: null, + shadow: null, + zoom: 1, + flipX: false, + adjust: null, + duotone: null, + cutout: null, + }); + expect(photoLookIsRaster(plain)).toBe(false); + expect(photoLookWords(plain)).toEqual([]); + }); + + it('BKLOOK-002: keeps every part it can read, and turns off a part it cannot rather than losing the photo', () => { + const look = { + border: { width: 4, color: '#ffffff' }, + shadow: { dx: 2, dy: -1.5, color: '#1a1a1a', alpha: 0.4 }, + zoom: 1.8, + flipX: true, + adjust: { brightness: 0.2, warmth: -0.3 }, + duotone: { dark: '#1e3a8a', light: '#f5e6c8' }, + cutout: { width: 3, color: '#ffeedd' }, + }; + expect(photo(look)).toMatchObject(look); + const odd = photo({ + border: { width: 900, color: '#fff' }, + zoom: 9, + flipX: 'yes', + adjust: { brightness: 4 }, + cutout: 'thick', + duotone: null, + }); + expect(odd.border).toBeUndefined(); + expect(odd.zoom).toBeUndefined(); + expect(odd.flipX).toBeUndefined(); + expect(odd.adjust).toBeUndefined(); + expect(odd.cutout).toBeUndefined(); + expect(odd.duotone).toBeNull(); + expect(odd.photoId).toBe(1); + }); + + it('BKLOOK-003: a stored book with a look survives normalising whole', () => { + const doc = book([photo({ border: { width: 5, color: '#ffffff' }, zoom: 2 })]); + const read = normalizeBookDocument(JSON.parse(JSON.stringify(doc))); + expect(inner(read).elements[0]).toMatchObject({ border: { width: 5, color: '#ffffff' }, zoom: 2 }); + }); +}); + +describe('reading the look', () => { + it('BKLOOK-004: an adjustment that moves nothing is none, and a partial one names every part', () => { + expect(photoAdjustOf(undefined)).toBeNull(); + expect(photoAdjustOf({ brightness: 0, contrast: 0 })).toBeNull(); + expect(photoAdjustOf({ contrast: 0.5 })).toEqual({ brightness: 0, contrast: 0.5, saturation: 0, warmth: 0 }); + expect(photoLookIsRaster(photo({ adjust: { saturation: -1 } }))).toBe(true); + expect(photoLookIsRaster(photo({ duotone: PHOTO_LOOK_STARTS.duotone }))).toBe(true); + expect(photoLookIsRaster(photo({ border: PHOTO_LOOK_STARTS.border, cutout: PHOTO_LOOK_STARTS.cutout }))).toBe( + false, + ); + }); + + it('BKLOOK-005: the zoom stays inside its range, and the zoom box keeps the focal point where it was', () => { + expect(photoZoomOf({ zoom: undefined })).toBe(PHOTO_ZOOM.min); + expect(photoZoomOf({ zoom: Number.NaN })).toBe(PHOTO_ZOOM.min); + expect(photoZoomOf({ zoom: 2.5 })).toBe(2.5); + expect(photoZoomOf({ zoom: 12 })).toBe(PHOTO_ZOOM.max); + expect(photoZoomBox(frame, 1, 0.3, 0.7)).toBe(frame); + const box = photoZoomBox(frame, 2, 0.25, 0.75); + expect(box).toEqual({ x: 210, y: -25, w: 160, h: 120 }); + // The focal point, as a fraction of the zoomed box, lands on the same place of the frame. + expect(box.x + box.w * 0.25).toBe(frame.x + frame.w * 0.25); + expect(box.y + box.h * 0.75).toBe(frame.y + frame.h * 0.75); + }); + + it('BKLOOK-006: says every part a photo wears, with the filters marked as raster in print', () => { + const words = photoLookWords( + photo({ + border: { width: 4, color: '#ffffff' }, + shadow: { dx: 2, dy: 2.04, color: '#1a1a1a', alpha: 0.35 }, + zoom: 1.5, + flipX: true, + adjust: { brightness: 0.2, contrast: -0.1 }, + duotone: { dark: '#000000', light: '#ffffff' }, + cutout: { width: 3, color: '#ffffff' }, + }), + ); + expect(words).toEqual([ + 'border 4 mm #ffffff', + 'hard shadow 2/2 mm #1a1a1a at 35%', + 'zoom x1.5', + 'mirrored', + 'adjusted brightness +0.2 contrast -0.1 (raster in print)', + 'duotone #000000 to #ffffff (raster in print)', + 'cutout 3 mm #ffffff', + ]); + }); +}); + +describe('the findings with a look', () => { + it('BKLOOK-007: a zoomed picture prints at the resolution its zoom leaves it', () => { + const plain = photoDpi(photo(), info); + const zoomed = photoDpi(photo({ zoom: 2 }), info); + expect(plain).toBe(1270); + expect(zoomed).toBe(635); + const big = photo({ frame: { x: 0, y: 0, w: 400, h: 300 }, zoom: 2 }); + expect(spreadFindings({ ...inner(book([big])) }, page, [info]).map((f) => f.code)).toContain('low_dpi'); + }); + + it('BKLOOK-008: the crop and the visible part follow the zoom, and a mirrored picture reads its busy grid mirrored', () => { + const zoomed = photo({ zoom: 2, focalX: 0, focalY: 0 }); + expect(bookPhotoCrop(zoomed, info)).toEqual({ x: 230, y: 20, w: 160, h: 120 }); + expect(bookVisiblePart(zoomed, info)).toEqual({ x: 0, y: 0, w: 0.5, h: 0.5 }); + // Busy in the left quarter of the picture, calm everywhere else. + const busy = 'ff000000'.repeat(8); + const left = { x: 230, y: 20, w: 15, h: 60 }; + const square = { photoId: 1, width: 3000, height: 3000, busy }; + const sq = { frame: { x: 230, y: 20, w: 60, h: 60 } }; + expect(bookBusyUnder(photo(sq), square, left)).toBe(15); + expect(bookBusyUnder(photo({ ...sq, flipX: true }), square, left)).toBe(0); + }); + + it('BKLOOK-009: a filtered picture over a quarter of a page is worth a word, a small one and an unfiltered one are not', () => { + const large = photo({ frame: { x: 230, y: 20, w: 150, h: 150 }, adjust: { contrast: 0.3 } }); + const both = photo({ + id: 'p2', + frame: { x: 0, y: 0, w: 210, h: 210 }, + adjust: { warmth: 1 }, + duotone: PHOTO_LOOK_STARTS.duotone, + }); + const small = photo({ id: 'p3', adjust: { contrast: 0.3 } }); + const plain = photo({ id: 'p4', frame: { x: 0, y: 0, w: 210, h: 210 } }); + const empty = photo({ id: 'p5', photoId: null, frame: { x: 0, y: 0, w: 210, h: 210 }, adjust: { contrast: 1 } }); + const spread = inner(book([large, both, small, plain, empty])); + const found = photoLookChecks(spread, page); + expect(found.map((f) => f.elementIds[0])).toEqual(['p1', 'p2']); + expect(found[0]).toMatchObject({ code: 'raster_effect', severity: 'info' }); + expect(found[1]?.message).toContain('adjustments and a duotone'); + expect(found.every((f) => f.message.length <= 300)).toBe(true); + expect(photoLookChecks(spread, { ...page, pageWidth: 0 })).toEqual([]); + expect(spreadFindings(inner(book([large])), page).map((f) => f.code)).toContain('raster_effect'); + }); + + it('BKLOOK-010: describe-spread lists the look beside the crop', () => { + const doc = book([photo({ border: { width: 4, color: '#ffffff' }, flipX: true })]); + const text = describeSpread(doc, 'sp-in', [info])?.text ?? ''; + expect(text).toContain('border 4 mm #ffffff'); + expect(text).toContain('mirrored'); + }); +}); + +describe('setting the look from the page edit', () => { + it('BKLOOK-011: style sets every part, and null turns a part off by leaving it out', () => { + const set = { + border: { width: 6, color: '#ffffff' }, + shadow: { dx: 3, dy: 3, color: '#000000', alpha: 0.5 }, + zoom: 1.4, + flipX: true, + adjust: { brightness: 0.1 }, + duotone: { dark: '#112233', light: '#ddeeff' }, + cutout: { width: 2, color: '#ffffff' }, + }; + const result = style(book([photo()]), set); + if (!result.ok) throw new Error(result.error.message); + expect(inner(result.doc).elements[0]).toMatchObject(set); + + const off = style(result.doc, { border: null, shadow: null, adjust: null, duotone: null, cutout: null }); + if (!off.ok) throw new Error(off.error.message); + const el = inner(off.doc).elements[0] as BookPhotoElement; + for (const key of ['border', 'shadow', 'adjust', 'duotone', 'cutout']) expect(key in el).toBe(false); + expect(el).toMatchObject({ zoom: 1.4, flipX: true }); + }); + + it('BKLOOK-012: the page edit refuses a look outside the format', () => { + const op = (set: Record) => + bookPageOpSchema.safeParse({ op: 'style', elementId: 'p1', set: { kind: 'photo', ...set } }).success; + expect(op({ zoom: 2 })).toBe(true); + expect(op({ zoom: 5 })).toBe(false); + expect(op({ border: { width: 40, color: '#ffffff' } })).toBe(false); + expect(op({ shadow: { dx: 2, dy: 2, color: '#000000', alpha: 2 } })).toBe(false); + expect(op({ adjust: { brightness: 0.5, glow: 1 } })).toBe(false); + expect(op({ duotone: { dark: 'navy', light: '#ffffff' } })).toBe(false); + // The look is styled onto a frame that exists, so a new one takes none of it. + const add = { op: 'add', element: { kind: 'photo', zoom: 2 } }; + expect(bookPageOpSchema.safeParse(add).success).toBe(false); + }); +}); diff --git a/shared/src/book/book-photo-look.ts b/shared/src/book/book-photo-look.ts new file mode 100644 index 000000000..e28955559 --- /dev/null +++ b/shared/src/book/book-photo-look.ts @@ -0,0 +1,138 @@ +import { + BOOK_PHOTO_LOOK_KEYS, + PHOTO_ZOOM, + type BookPhotoAdjust, + type BookPhotoDuotone, + type BookPhotoEdge, + type BookPhotoShadow, +} from './book-photo-look.schema'; +import type { BookFrame, BookPhotoElement } from './book.schema'; + +/** + * Reading the photo look (book-photo-look.schema.ts) the way the renderer, + * the findings and the words for a model all read it: off where a field is + * missing or null, an adjustment that changes nothing counted as none, the + * zoom clamped to what the format allows. + */ + +/** The four adjustments, in the order the inspector lists them. */ +export const PHOTO_ADJUSTMENTS = ['brightness', 'contrast', 'saturation', 'warmth'] as const; +export type PhotoAdjustment = (typeof PHOTO_ADJUSTMENTS)[number]; + +/** The look of one photo, every part resolved. */ +export interface PhotoLook { + border: BookPhotoEdge | null; + shadow: BookPhotoShadow | null; + zoom: number; + flipX: boolean; + /** Null when every adjustment is 0. */ + adjust: Required | null; + duotone: BookPhotoDuotone | null; + cutout: BookPhotoEdge | null; +} + +type LookSource = Pick; + +/** The adjustments with every one named, or null when none of them moves the picture. */ +export function photoAdjustOf(adjust: BookPhotoAdjust | null | undefined): Required | null { + if (!adjust) return null; + const full = { + brightness: adjust.brightness ?? 0, + contrast: adjust.contrast ?? 0, + saturation: adjust.saturation ?? 0, + warmth: adjust.warmth ?? 0, + }; + return PHOTO_ADJUSTMENTS.some((k) => full[k] !== 0) ? full : null; +} + +/** The zoom a photo is drawn at: 1 when unset, inside the format's range otherwise. */ +export function photoZoomOf(el: Pick): number { + const z = el.zoom; + if (typeof z !== 'number' || !Number.isFinite(z)) return PHOTO_ZOOM.min; + return Math.min(PHOTO_ZOOM.max, Math.max(PHOTO_ZOOM.min, z)); +} + +export function photoLookOf(el: LookSource): PhotoLook { + return { + border: el.border ?? null, + shadow: el.shadow ?? null, + zoom: photoZoomOf(el), + flipX: el.flipX === true, + adjust: photoAdjustOf(el.adjust), + duotone: el.duotone ?? null, + cutout: el.cutout ?? null, + }; +} + +/** Whether the look draws the picture through a filter, which prints it as a raster. */ +export function photoLookIsRaster(el: LookSource): boolean { + const look = photoLookOf(el); + return look.adjust !== null || look.duotone !== null; +} + +/** + * The box the picture is fitted into, in the frame's own coordinates: the + * frame itself at zoom 1, and at zoom z a box z times its size placed so the + * focal point sits where it sat. That is what keeps a focal point meaning the + * same part of the picture at every zoom. + */ +export function photoZoomBox(frame: BookFrame, zoom: number, focalX: number, focalY: number): BookFrame { + if (zoom <= 1) return frame; + return { + x: frame.x - (zoom - 1) * frame.w * focalX, + y: frame.y - (zoom - 1) * frame.h * focalY, + w: frame.w * zoom, + h: frame.h * zoom, + }; +} + +const n = (v: number) => `${Math.round(v * 10) / 10}`; +const signed = (v: number) => `${v > 0 ? '+' : ''}${Math.round(v * 100) / 100}`; + +/** The look in words, for a model reading a page; empty when the photo wears none of it. */ +export function photoLookWords(el: LookSource): string[] { + const look = photoLookOf(el); + const parts: string[] = []; + if (look.border) parts.push(`border ${n(look.border.width)} mm ${look.border.color}`); + if (look.shadow) { + const s = look.shadow; + parts.push(`hard shadow ${n(s.dx)}/${n(s.dy)} mm ${s.color} at ${Math.round(s.alpha * 100)}%`); + } + if (look.zoom > 1) parts.push(`zoom x${Math.round(look.zoom * 100) / 100}`); + if (look.flipX) parts.push('mirrored'); + if (look.adjust) { + const a = look.adjust; + const moved = PHOTO_ADJUSTMENTS.filter((k) => a[k] !== 0).map((k) => `${k} ${signed(a[k])}`); + parts.push(`adjusted ${moved.join(' ')} (raster in print)`); + } + if (look.duotone) parts.push(`duotone ${look.duotone.dark} to ${look.duotone.light} (raster in print)`); + if (look.cutout) parts.push(`cutout ${n(look.cutout.width)} mm ${look.cutout.color}`); + return parts; +} + +/** + * Where a part of the look starts when someone turns it on: a white print + * border, a soft-black shadow down and to the right, a white die-cut edge, + * and a navy to sand duotone. Print ink, not app chrome, which is why they + * live here rather than in the theme. + */ +export const PHOTO_LOOK_STARTS = { + border: { width: 4, color: '#ffffff' }, + shadow: { dx: 2, dy: 2, color: '#1a1a1a', alpha: 0.35 }, + cutout: { width: 3, color: '#ffffff' }, + duotone: { dark: '#1e3a8a', light: '#f5e6c8' }, +} as const satisfies { + border: BookPhotoEdge; + shadow: BookPhotoShadow; + cutout: BookPhotoEdge; + duotone: BookPhotoDuotone; +}; + +/** The duotones the inspector offers in one press, dark ink first: navy on sand, ink on paper, plum, forest, wine. */ +export const PHOTO_DUOTONES = [ + { dark: '#1e3a8a', light: '#f5e6c8' }, + { dark: '#141414', light: '#f4efe6' }, + { dark: '#4c1d95', light: '#fbcfb8' }, + { dark: '#0f3d2e', light: '#e6efd6' }, + { dark: '#9f1239', light: '#fde68a' }, +] as const satisfies readonly BookPhotoDuotone[]; diff --git a/shared/src/book/book-schema-rev.spec.ts b/shared/src/book/book-schema-rev.spec.ts index ec4e7b3fb..5c21c4292 100644 --- a/shared/src/book/book-schema-rev.spec.ts +++ b/shared/src/book/book-schema-rev.spec.ts @@ -20,6 +20,8 @@ import { describe, expect, it } from 'vitest'; */ const frame = { x: 12, y: 18, w: 60, h: 40 }; +/** A revision newer than this build reads. */ +const NEWER = BOOK_SCHEMA_REV + 1; const doc = (over: Record = {}) => ({ version: 1, @@ -40,7 +42,7 @@ const doc = (over: Record = {}) => ({ describe('the revision a document was written by', () => { it('BKREV-001: reads 1 for every document from before the field, and for anything that is not a whole number', () => { - expect(BOOK_SCHEMA_REV).toBe(1); + expect(BOOK_SCHEMA_REV).toBeGreaterThanOrEqual(2); expect(bookSchemaRev(doc())).toBe(1); expect(bookSchemaRev(null)).toBe(1); expect(bookSchemaRev('{"schemaRev":4}')).toBe(1); @@ -56,33 +58,35 @@ describe('the revision a document was written by', () => { expect(odd.success && odd.data.spreads[0]?.elements).toHaveLength(1); }); - it('BKREV-003: leaves a book saved today as it was, with no revision written into it', () => { + it('BKREV-003: a writer of revision 1 leaves a book as it was, and this build stamps its own revision', () => { const today = normalizeBookDocument(doc()); expect('schemaRev' in today).toBe(false); - expect(stampBookSchemaRev(today)).toBe(today); - expect(JSON.stringify(stampBookSchemaRev(today))).toBe(JSON.stringify(today)); + expect(stampBookSchemaRev(today, 1)).toBe(today); + expect(JSON.stringify(stampBookSchemaRev(today, 1))).toBe(JSON.stringify(today)); + expect(stampBookSchemaRev(today).schemaRev).toBe(BOOK_SCHEMA_REV); }); }); describe('a reader older than the document', () => { it('BKREV-004: knows the document is newer than it, read from the raw value even when the rest will not parse', () => { - expect(isBookNewerThanReader(doc({ schemaRev: 2 }))).toBe(true); + expect(isBookNewerThanReader(doc({ schemaRev: NEWER }))).toBe(true); + expect(isBookNewerThanReader(doc({ schemaRev: BOOK_SCHEMA_REV }))).toBe(false); expect(isBookNewerThanReader(doc({ schemaRev: 1 }))).toBe(false); expect(isBookNewerThanReader(doc())).toBe(false); // A reader of a later revision reads the same document as its own. - expect(isBookNewerThanReader(doc({ schemaRev: 2 }), 2)).toBe(false); - const unreadable = doc({ schemaRev: 5, page: 'not a page' }); + expect(isBookNewerThanReader(doc({ schemaRev: NEWER }), NEWER)).toBe(false); + const unreadable = doc({ schemaRev: NEWER + 3, page: 'not a page' }); expect(isBookNewerThanReader(unreadable)).toBe(true); }); it('BKREV-005: reads a newer book as far as it can and says so, so nothing it dropped is saved', () => { const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' }; - const raw = doc({ schemaRev: 2 }); + const raw = doc({ schemaRev: NEWER }); const spread = (raw.spreads as { elements: unknown[] }[])[0]; if (spread) spread.elements.push(sticker); const read = readBookDocument(raw); expect(read.newer).toBe(true); - expect(read.document.schemaRev).toBe(2); + expect(read.document.schemaRev).toBe(NEWER); expect(read.document.spreads[0]?.elements.map((e) => e.id)).toEqual(['t1']); expect(readBookDocument(doc()).newer).toBe(false); }); @@ -99,10 +103,10 @@ describe('a reader older than the document', () => { describe('a save request carrying a newer book', () => { it('BKREV-007: reaches the server as its revision alone, so the save is refused there rather than at the pipe', () => { const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' }; - const raw = doc({ schemaRev: 2 }); + const raw = doc({ schemaRev: NEWER }); const spread = (raw.spreads as { elements: unknown[] }[])[0]; if (spread) spread.elements.push(sticker); - expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: 2 }); + expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: NEWER }); // A book of today still reads whole, revision or none. const today = bookSaveRequestSchema.parse({ title: 'T', document: doc() }).document; expect(today).toEqual(bookDocumentSchema.parse(doc())); diff --git a/shared/src/book/book-schema-rev.ts b/shared/src/book/book-schema-rev.ts index 9a3adfa8c..bc8377b91 100644 --- a/shared/src/book/book-schema-rev.ts +++ b/shared/src/book/book-schema-rev.ts @@ -28,7 +28,12 @@ import type { BookDocument } from './book.schema'; * saves only once that is above 1, so a book saved today stores exactly what * it stored before this existed. */ -export const BOOK_SCHEMA_REV = 1; +/** + * The revisions so far: + * + * - 2: the photo look (`border`, `shadow`, `zoom`, `flipX`, `adjust`, `duotone`, `cutout`, book-photo-look.schema.ts). + */ +export const BOOK_SCHEMA_REV = 2; /** 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'; diff --git a/shared/src/book/book.schema.ts b/shared/src/book/book.schema.ts index e4974876b..45391e9fd 100644 --- a/shared/src/book/book.schema.ts +++ b/shared/src/book/book.schema.ts @@ -1,3 +1,5 @@ +import { BOOK_PHOTO_LOOK_FIELDS } from './book-photo-look.schema'; + import { z } from 'zod'; /** @@ -221,6 +223,8 @@ export const bookPhotoElementSchema = z.object({ * and it is the one thing you cannot express with either. */ frameStyle: z.enum(['none', 'polaroid', 'white', 'shadow', 'film', 'tape']).default('none'), + /** Border, hard shadow, zoom, mirror, adjustments, duotone and cutout (book-photo-look.schema.ts). */ + ...BOOK_PHOTO_LOOK_FIELDS, }); export const bookTextElementSchema = z.object({ diff --git a/shared/src/book/describe-spread.ts b/shared/src/book/describe-spread.ts index 01d2994bc..feda69148 100644 --- a/shared/src/book/describe-spread.ts +++ b/shared/src/book/describe-spread.ts @@ -10,6 +10,7 @@ import type { BookPageDescription, BookSpreadSummary, } from './book-ops.schema'; +import { photoLookWords } from './book-photo-look'; import { bookPageSetupSchema, type BookDocument, @@ -74,6 +75,7 @@ function photoWords(el: BookPhotoElement, info: BookPhotoInfo | undefined): stri if (el.mask) parts.push(`mask ${el.mask}`); if (el.frameStyle !== 'none') parts.push(`frame style ${el.frameStyle}`); if (el.radius > 0) parts.push(`radius ${n(el.radius)}`); + parts.push(...photoLookWords(el)); return parts.join(', '); } diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/ar/index.ts +++ b/shared/src/i18n/ar/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ar/studioPhoto.ts b/shared/src/i18n/ar/studioPhoto.ts new file mode 100644 index 000000000..04a210ca1 --- /dev/null +++ b/shared/src/i18n/ar/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'التكبير', + 'journey.studio.photo.flip': 'عكس الصورة', + 'journey.studio.photo.dpi': 'تُطبع بدقة {dpi} نقطة لكل بوصة تقريبًا', + 'journey.studio.photo.dpiLow': 'دون {min} نقطة لكل بوصة تبدو الطباعة باهتة. صغّر الإطار أو قلّل التكبير.', + 'journey.studio.photo.edges': 'الحد والظل', + 'journey.studio.photo.border': 'الحد', + 'journey.studio.photo.width': 'العرض', + 'journey.studio.photo.shadow': 'ظل حاد', + 'journey.studio.photo.dx': 'أفقيًا', + 'journey.studio.photo.dy': 'عموديًا', + 'journey.studio.photo.cutout': 'حافة القص', + 'journey.studio.photo.adjust': 'ضبط', + 'journey.studio.photo.brightness': 'السطوع', + 'journey.studio.photo.contrast': 'التباين', + 'journey.studio.photo.saturation': 'التشبع', + 'journey.studio.photo.warmth': 'الدفء', + 'journey.studio.photo.reset': 'إعادة الضبط', + 'journey.studio.photo.rasterHint': 'تُطبع كصورة نقطية، مثل المرشحات.', + 'journey.studio.photo.duotone': 'لونان', + 'journey.studio.photo.duotoneDark': 'الظلال', + 'journey.studio.photo.duotoneLight': 'الإضاءات', + 'assistant.card.book.finding.raster_effect': 'مرشحات على صورة كبيرة', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/az/index.ts b/shared/src/i18n/az/index.ts index 4c107a668..a0d860e09 100644 --- a/shared/src/i18n/az/index.ts +++ b/shared/src/i18n/az/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/az/studioPhoto.ts b/shared/src/i18n/az/studioPhoto.ts new file mode 100644 index 000000000..db4cdea29 --- /dev/null +++ b/shared/src/i18n/az/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Böyütmə', + 'journey.studio.photo.flip': 'Güzgü kimi çevir', + 'journey.studio.photo.dpi': 'Təxminən {dpi} dpi ilə çap olunur', + 'journey.studio.photo.dpiLow': '{min} dpi-dən aşağı çap yumşaq görünür. Çərçivəni kiçildin və ya uzaqlaşdırın.', + 'journey.studio.photo.edges': 'Haşiyə və kölgə', + 'journey.studio.photo.border': 'Haşiyə', + 'journey.studio.photo.width': 'En', + 'journey.studio.photo.shadow': 'Sərt kölgə', + 'journey.studio.photo.dx': 'Üfüqi', + 'journey.studio.photo.dy': 'Şaquli', + 'journey.studio.photo.cutout': 'Kəsim haşiyəsi', + 'journey.studio.photo.adjust': 'Tənzimlə', + 'journey.studio.photo.brightness': 'Parlaqlıq', + 'journey.studio.photo.contrast': 'Kontrast', + 'journey.studio.photo.saturation': 'Doyğunluq', + 'journey.studio.photo.warmth': 'İstilik', + 'journey.studio.photo.reset': 'Sıfırla', + 'journey.studio.photo.rasterHint': 'Filtrlər kimi raster şəkil olaraq çap olunur.', + 'journey.studio.photo.duotone': 'İkiton', + 'journey.studio.photo.duotoneDark': 'Kölgələr', + 'journey.studio.photo.duotoneLight': 'İşıqlı yerlər', + 'assistant.card.book.finding.raster_effect': 'Böyük fotoda filtrlər', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts index 510bca449..d368e43d5 100644 --- a/shared/src/i18n/br/index.ts +++ b/shared/src/i18n/br/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/br/studioPhoto.ts b/shared/src/i18n/br/studioPhoto.ts new file mode 100644 index 000000000..200576418 --- /dev/null +++ b/shared/src/i18n/br/studioPhoto.ts @@ -0,0 +1,30 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', // same-as-en + 'journey.studio.photo.flip': 'Espelhar', + 'journey.studio.photo.dpi': 'Imprime a cerca de {dpi} dpi', + 'journey.studio.photo.dpiLow': + 'Abaixo de {min} dpi a impressão fica suave demais. Diminua a moldura ou afaste o zoom.', + 'journey.studio.photo.edges': 'Borda e sombra', + 'journey.studio.photo.border': 'Borda', + 'journey.studio.photo.width': 'Largura', + 'journey.studio.photo.shadow': 'Sombra dura', + 'journey.studio.photo.dx': 'Horizontal', + 'journey.studio.photo.dy': 'Vertical', + 'journey.studio.photo.cutout': 'Borda recortada', + 'journey.studio.photo.adjust': 'Ajustar', + 'journey.studio.photo.brightness': 'Brilho', + 'journey.studio.photo.contrast': 'Contraste', + 'journey.studio.photo.saturation': 'Saturação', + 'journey.studio.photo.warmth': 'Calor', + 'journey.studio.photo.reset': 'Redefinir', + 'journey.studio.photo.rasterHint': 'É impressa como imagem rasterizada, como os filtros.', + 'journey.studio.photo.duotone': 'Duotônico', + 'journey.studio.photo.duotoneDark': 'Sombras', + 'journey.studio.photo.duotoneLight': 'Realces', + 'assistant.card.book.finding.raster_effect': 'Filtros em uma foto grande', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts index d46616492..c4c0e0556 100644 --- a/shared/src/i18n/ca/index.ts +++ b/shared/src/i18n/ca/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -115,6 +116,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ca/studioPhoto.ts b/shared/src/i18n/ca/studioPhoto.ts new file mode 100644 index 000000000..e5af2a824 --- /dev/null +++ b/shared/src/i18n/ca/studioPhoto.ts @@ -0,0 +1,30 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', // same-as-en + 'journey.studio.photo.flip': 'Emmiralla', + 'journey.studio.photo.dpi': 'S’imprimeix a uns {dpi} ppp', + 'journey.studio.photo.dpiLow': + 'Per sota de {min} ppp una impressió es veu tova. Fes el marc més petit o allunya el zoom.', + 'journey.studio.photo.edges': 'Vora i ombra', + 'journey.studio.photo.border': 'Vora', + 'journey.studio.photo.width': 'Amplada', + 'journey.studio.photo.shadow': 'Ombra dura', + 'journey.studio.photo.dx': 'Horitzontal', + 'journey.studio.photo.dy': 'Vertical', + 'journey.studio.photo.cutout': 'Vora retallada', + 'journey.studio.photo.adjust': 'Ajusta', + 'journey.studio.photo.brightness': 'Brillantor', + 'journey.studio.photo.contrast': 'Contrast', // same-as-en + 'journey.studio.photo.saturation': 'Saturació', + 'journey.studio.photo.warmth': 'Calidesa', + 'journey.studio.photo.reset': 'Restableix', + 'journey.studio.photo.rasterHint': 'S’imprimeix com a imatge ràster, com els filtres.', + 'journey.studio.photo.duotone': 'Duotò', + 'journey.studio.photo.duotoneDark': 'Ombres', + 'journey.studio.photo.duotoneLight': 'Llums', + 'assistant.card.book.finding.raster_effect': 'Filtres en una foto gran', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/cs/index.ts +++ b/shared/src/i18n/cs/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/cs/studioPhoto.ts b/shared/src/i18n/cs/studioPhoto.ts new file mode 100644 index 000000000..cf01233c1 --- /dev/null +++ b/shared/src/i18n/cs/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Přiblížení', + 'journey.studio.photo.flip': 'Zrcadlit', + 'journey.studio.photo.dpi': 'Tiskne se zhruba v {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Pod {min} dpi vypadá tisk měkce. Zmenšete rámeček nebo oddalte.', + 'journey.studio.photo.edges': 'Okraj a stín', + 'journey.studio.photo.border': 'Okraj', + 'journey.studio.photo.width': 'Šířka', + 'journey.studio.photo.shadow': 'Ostrý stín', + 'journey.studio.photo.dx': 'Vodorovně', + 'journey.studio.photo.dy': 'Svisle', + 'journey.studio.photo.cutout': 'Vysekaný okraj', + 'journey.studio.photo.adjust': 'Úpravy', + 'journey.studio.photo.brightness': 'Jas', + 'journey.studio.photo.contrast': 'Kontrast', + 'journey.studio.photo.saturation': 'Sytost', + 'journey.studio.photo.warmth': 'Teplota', + 'journey.studio.photo.reset': 'Obnovit', + 'journey.studio.photo.rasterHint': 'Tiskne se jako rastrový obrázek, stejně jako filtry.', + 'journey.studio.photo.duotone': 'Duotón', + 'journey.studio.photo.duotoneDark': 'Stíny', + 'journey.studio.photo.duotoneLight': 'Světla', + 'assistant.card.book.finding.raster_effect': 'Filtry na velké fotce', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/de/index.ts +++ b/shared/src/i18n/de/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/de/studioPhoto.ts b/shared/src/i18n/de/studioPhoto.ts new file mode 100644 index 000000000..fb76bdbc0 --- /dev/null +++ b/shared/src/i18n/de/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', // same-as-en + 'journey.studio.photo.flip': 'Spiegeln', + 'journey.studio.photo.dpi': 'Druckt mit etwa {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Unter {min} dpi wirkt ein Druck weich. Verkleinere den Rahmen oder zoome heraus.', + 'journey.studio.photo.edges': 'Rand und Schatten', + 'journey.studio.photo.border': 'Rand', + 'journey.studio.photo.width': 'Breite', + 'journey.studio.photo.shadow': 'Harter Schatten', + 'journey.studio.photo.dx': 'Seitlich', + 'journey.studio.photo.dy': 'Nach unten', + 'journey.studio.photo.cutout': 'Stanzrand', + 'journey.studio.photo.adjust': 'Anpassen', + 'journey.studio.photo.brightness': 'Helligkeit', + 'journey.studio.photo.contrast': 'Kontrast', + 'journey.studio.photo.saturation': 'Sättigung', + 'journey.studio.photo.warmth': 'Wärme', + 'journey.studio.photo.reset': 'Zurücksetzen', + 'journey.studio.photo.rasterHint': 'Wird als Rasterbild gedruckt, wie die Filter.', + 'journey.studio.photo.duotone': 'Duoton', + 'journey.studio.photo.duotoneDark': 'Tiefen', + 'journey.studio.photo.duotoneLight': 'Lichter', + 'assistant.card.book.finding.raster_effect': 'Filter auf einem großen Foto', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/en/index.ts +++ b/shared/src/i18n/en/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/en/studioPhoto.ts b/shared/src/i18n/en/studioPhoto.ts new file mode 100644 index 000000000..5be9838da --- /dev/null +++ b/shared/src/i18n/en/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', + 'journey.studio.photo.flip': 'Mirror', + 'journey.studio.photo.dpi': 'Prints at about {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Under {min} dpi a print looks soft. Make the frame smaller or zoom out.', + 'journey.studio.photo.edges': 'Border and shadow', + 'journey.studio.photo.border': 'Border', + 'journey.studio.photo.width': 'Width', + 'journey.studio.photo.shadow': 'Hard shadow', + 'journey.studio.photo.dx': 'Across', + 'journey.studio.photo.dy': 'Down', + 'journey.studio.photo.cutout': 'Cutout edge', + 'journey.studio.photo.adjust': 'Adjust', + 'journey.studio.photo.brightness': 'Brightness', + 'journey.studio.photo.contrast': 'Contrast', + 'journey.studio.photo.saturation': 'Saturation', + 'journey.studio.photo.warmth': 'Warmth', + 'journey.studio.photo.reset': 'Reset', + 'journey.studio.photo.rasterHint': 'Prints as a raster image, like the filters.', + 'journey.studio.photo.duotone': 'Duotone', + 'journey.studio.photo.duotoneDark': 'Shadows', + 'journey.studio.photo.duotoneLight': 'Highlights', + 'assistant.card.book.finding.raster_effect': 'Filters on a large photo', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts index 745fbfc63..3ea6659a0 100644 --- a/shared/src/i18n/es/index.ts +++ b/shared/src/i18n/es/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/es/studioPhoto.ts b/shared/src/i18n/es/studioPhoto.ts new file mode 100644 index 000000000..34b266abd --- /dev/null +++ b/shared/src/i18n/es/studioPhoto.ts @@ -0,0 +1,30 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', // same-as-en + 'journey.studio.photo.flip': 'Reflejar', + 'journey.studio.photo.dpi': 'Se imprime a unos {dpi} ppp', + 'journey.studio.photo.dpiLow': + 'Por debajo de {min} ppp una impresión se ve borrosa. Reduce el marco o aleja el zoom.', + 'journey.studio.photo.edges': 'Borde y sombra', + 'journey.studio.photo.border': 'Borde', + 'journey.studio.photo.width': 'Ancho', + 'journey.studio.photo.shadow': 'Sombra dura', + 'journey.studio.photo.dx': 'Horizontal', + 'journey.studio.photo.dy': 'Vertical', + 'journey.studio.photo.cutout': 'Borde troquelado', + 'journey.studio.photo.adjust': 'Ajustar', + 'journey.studio.photo.brightness': 'Brillo', + 'journey.studio.photo.contrast': 'Contraste', + 'journey.studio.photo.saturation': 'Saturación', + 'journey.studio.photo.warmth': 'Calidez', + 'journey.studio.photo.reset': 'Restablecer', + 'journey.studio.photo.rasterHint': 'Se imprime como imagen rasterizada, igual que los filtros.', + 'journey.studio.photo.duotone': 'Duotono', + 'journey.studio.photo.duotoneDark': 'Sombras', + 'journey.studio.photo.duotoneLight': 'Luces', + 'assistant.card.book.finding.raster_effect': 'Filtros en una foto grande', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/et/index.ts b/shared/src/i18n/et/index.ts index 4c107a668..a0d860e09 100644 --- a/shared/src/i18n/et/index.ts +++ b/shared/src/i18n/et/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/et/studioPhoto.ts b/shared/src/i18n/et/studioPhoto.ts new file mode 100644 index 000000000..df12a07ec --- /dev/null +++ b/shared/src/i18n/et/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Suum', + 'journey.studio.photo.flip': 'Peegelda', + 'journey.studio.photo.dpi': 'Trükitakse umbes {dpi} dpi-ga', + 'journey.studio.photo.dpiLow': 'Alla {min} dpi näeb trükk pehme välja. Tee raam väiksemaks või suumi välja.', + 'journey.studio.photo.edges': 'Ääris ja vari', + 'journey.studio.photo.border': 'Ääris', + 'journey.studio.photo.width': 'Laius', + 'journey.studio.photo.shadow': 'Terav vari', + 'journey.studio.photo.dx': 'Külgsuunas', + 'journey.studio.photo.dy': 'Allapoole', + 'journey.studio.photo.cutout': 'Väljalõike serv', + 'journey.studio.photo.adjust': 'Kohanda', + 'journey.studio.photo.brightness': 'Heledus', + 'journey.studio.photo.contrast': 'Kontrastsus', + 'journey.studio.photo.saturation': 'Küllastus', + 'journey.studio.photo.warmth': 'Soojus', + 'journey.studio.photo.reset': 'Lähtesta', + 'journey.studio.photo.rasterHint': 'Trükitakse rasterpildina, nagu filtridki.', + 'journey.studio.photo.duotone': 'Duotoon', + 'journey.studio.photo.duotoneDark': 'Varjud', + 'journey.studio.photo.duotoneLight': 'Heledad toonid', + 'assistant.card.book.finding.raster_effect': 'Filtrid suurel fotol', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/fr/index.ts +++ b/shared/src/i18n/fr/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/fr/studioPhoto.ts b/shared/src/i18n/fr/studioPhoto.ts new file mode 100644 index 000000000..e8c8dce53 --- /dev/null +++ b/shared/src/i18n/fr/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', // same-as-en + 'journey.studio.photo.flip': 'Miroir', + 'journey.studio.photo.dpi': 'Imprimée à environ {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Sous {min} dpi, un tirage paraît flou. Réduisez le cadre ou dézoomez.', + 'journey.studio.photo.edges': 'Bordure et ombre', + 'journey.studio.photo.border': 'Bordure', + 'journey.studio.photo.width': 'Largeur', + 'journey.studio.photo.shadow': 'Ombre nette', + 'journey.studio.photo.dx': 'Horizontal', + 'journey.studio.photo.dy': 'Vertical', + 'journey.studio.photo.cutout': 'Contour découpé', + 'journey.studio.photo.adjust': 'Ajuster', + 'journey.studio.photo.brightness': 'Luminosité', + 'journey.studio.photo.contrast': 'Contraste', + 'journey.studio.photo.saturation': 'Saturation', // same-as-en + 'journey.studio.photo.warmth': 'Chaleur', + 'journey.studio.photo.reset': 'Réinitialiser', + 'journey.studio.photo.rasterHint': 'S’imprime en image matricielle, comme les filtres.', + 'journey.studio.photo.duotone': 'Bichromie', + 'journey.studio.photo.duotoneDark': 'Ombres', + 'journey.studio.photo.duotoneLight': 'Hautes lumières', + 'assistant.card.book.finding.raster_effect': 'Filtres sur une grande photo', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/gr/index.ts +++ b/shared/src/i18n/gr/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/gr/studioPhoto.ts b/shared/src/i18n/gr/studioPhoto.ts new file mode 100644 index 000000000..5e6963f43 --- /dev/null +++ b/shared/src/i18n/gr/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Μεγέθυνση', + 'journey.studio.photo.flip': 'Κατοπτρισμός', + 'journey.studio.photo.dpi': 'Τυπώνεται σε περίπου {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Κάτω από {min} dpi η εκτύπωση δείχνει θολή. Μίκρυνε το πλαίσιο ή κάνε σμίκρυνση.', + 'journey.studio.photo.edges': 'Περίγραμμα και σκιά', + 'journey.studio.photo.border': 'Περίγραμμα', + 'journey.studio.photo.width': 'Πλάτος', + 'journey.studio.photo.shadow': 'Σκληρή σκιά', + 'journey.studio.photo.dx': 'Οριζόντια', + 'journey.studio.photo.dy': 'Κάθετα', + 'journey.studio.photo.cutout': 'Περίγραμμα κοπής', + 'journey.studio.photo.adjust': 'Προσαρμογή', + 'journey.studio.photo.brightness': 'Φωτεινότητα', + 'journey.studio.photo.contrast': 'Αντίθεση', + 'journey.studio.photo.saturation': 'Κορεσμός', + 'journey.studio.photo.warmth': 'Θερμότητα', + 'journey.studio.photo.reset': 'Επαναφορά', + 'journey.studio.photo.rasterHint': 'Τυπώνεται ως εικόνα ράστερ, όπως τα φίλτρα.', + 'journey.studio.photo.duotone': 'Δίτονο', + 'journey.studio.photo.duotoneDark': 'Σκιές', + 'journey.studio.photo.duotoneLight': 'Φωτεινά σημεία', + 'assistant.card.book.finding.raster_effect': 'Φίλτρα σε μεγάλη φωτογραφία', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts index 510bca449..d368e43d5 100644 --- a/shared/src/i18n/hu/index.ts +++ b/shared/src/i18n/hu/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/hu/studioPhoto.ts b/shared/src/i18n/hu/studioPhoto.ts new file mode 100644 index 000000000..1e1ffc4df --- /dev/null +++ b/shared/src/i18n/hu/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Nagyítás', + 'journey.studio.photo.flip': 'Tükrözés', + 'journey.studio.photo.dpi': 'Körülbelül {dpi} dpi-vel nyomtatódik', + 'journey.studio.photo.dpiLow': '{min} dpi alatt a nyomat lágynak hat. Kicsinyítsd a keretet, vagy nagyíts ki.', + 'journey.studio.photo.edges': 'Szegély és árnyék', + 'journey.studio.photo.border': 'Szegély', + 'journey.studio.photo.width': 'Szélesség', + 'journey.studio.photo.shadow': 'Éles árnyék', + 'journey.studio.photo.dx': 'Vízszintesen', + 'journey.studio.photo.dy': 'Függőlegesen', + 'journey.studio.photo.cutout': 'Kivágott szegély', + 'journey.studio.photo.adjust': 'Beállítás', + 'journey.studio.photo.brightness': 'Fényerő', + 'journey.studio.photo.contrast': 'Kontraszt', + 'journey.studio.photo.saturation': 'Telítettség', + 'journey.studio.photo.warmth': 'Melegség', + 'journey.studio.photo.reset': 'Visszaállítás', + 'journey.studio.photo.rasterHint': 'Raszteres képként nyomtatódik, mint a szűrők.', + 'journey.studio.photo.duotone': 'Duotónus', + 'journey.studio.photo.duotoneDark': 'Árnyékok', + 'journey.studio.photo.duotoneLight': 'Csúcsfények', + 'assistant.card.book.finding.raster_effect': 'Szűrők egy nagy fotón', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/id/index.ts +++ b/shared/src/i18n/id/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/id/studioPhoto.ts b/shared/src/i18n/id/studioPhoto.ts new file mode 100644 index 000000000..985f27113 --- /dev/null +++ b/shared/src/i18n/id/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Perbesar', + 'journey.studio.photo.flip': 'Cerminkan', + 'journey.studio.photo.dpi': 'Dicetak sekitar {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Di bawah {min} dpi hasil cetak tampak lembut. Perkecil bingkai atau perkecil zoom.', + 'journey.studio.photo.edges': 'Bingkai dan bayangan', + 'journey.studio.photo.border': 'Bingkai', + 'journey.studio.photo.width': 'Lebar', + 'journey.studio.photo.shadow': 'Bayangan tegas', + 'journey.studio.photo.dx': 'Mendatar', + 'journey.studio.photo.dy': 'Menurun', + 'journey.studio.photo.cutout': 'Tepi potongan', + 'journey.studio.photo.adjust': 'Sesuaikan', + 'journey.studio.photo.brightness': 'Kecerahan', + 'journey.studio.photo.contrast': 'Kontras', + 'journey.studio.photo.saturation': 'Saturasi', + 'journey.studio.photo.warmth': 'Kehangatan', + 'journey.studio.photo.reset': 'Atur ulang', + 'journey.studio.photo.rasterHint': 'Dicetak sebagai gambar raster, seperti filter.', + 'journey.studio.photo.duotone': 'Dua nada', + 'journey.studio.photo.duotoneDark': 'Bayangan', + 'journey.studio.photo.duotoneLight': 'Sorotan', + 'assistant.card.book.finding.raster_effect': 'Filter pada foto besar', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/it/index.ts +++ b/shared/src/i18n/it/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/it/studioPhoto.ts b/shared/src/i18n/it/studioPhoto.ts new file mode 100644 index 000000000..640aa3117 --- /dev/null +++ b/shared/src/i18n/it/studioPhoto.ts @@ -0,0 +1,30 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', // same-as-en + 'journey.studio.photo.flip': 'Specchia', + 'journey.studio.photo.dpi': 'Stampa a circa {dpi} dpi', + 'journey.studio.photo.dpiLow': + 'Sotto i {min} dpi una stampa appare morbida. Rimpicciolisci la cornice o riduci lo zoom.', + 'journey.studio.photo.edges': 'Bordo e ombra', + 'journey.studio.photo.border': 'Bordo', + 'journey.studio.photo.width': 'Larghezza', + 'journey.studio.photo.shadow': 'Ombra netta', + 'journey.studio.photo.dx': 'In orizzontale', + 'journey.studio.photo.dy': 'In verticale', + 'journey.studio.photo.cutout': 'Bordo fustellato', + 'journey.studio.photo.adjust': 'Regola', + 'journey.studio.photo.brightness': 'Luminosità', + 'journey.studio.photo.contrast': 'Contrasto', + 'journey.studio.photo.saturation': 'Saturazione', + 'journey.studio.photo.warmth': 'Calore', + 'journey.studio.photo.reset': 'Ripristina', + 'journey.studio.photo.rasterHint': 'Viene stampata come immagine raster, come i filtri.', + 'journey.studio.photo.duotone': 'Bicromia', + 'journey.studio.photo.duotoneDark': 'Ombre', + 'journey.studio.photo.duotoneLight': 'Luci', + 'assistant.card.book.finding.raster_effect': 'Filtri su una foto grande', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/ja/index.ts +++ b/shared/src/i18n/ja/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ja/studioPhoto.ts b/shared/src/i18n/ja/studioPhoto.ts new file mode 100644 index 000000000..c1675259a --- /dev/null +++ b/shared/src/i18n/ja/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'ズーム', + 'journey.studio.photo.flip': '左右反転', + 'journey.studio.photo.dpi': '約 {dpi} dpi で印刷されます', + 'journey.studio.photo.dpiLow': '{min} dpi を下回ると印刷がぼやけて見えます。枠を小さくするかズームを下げてください。', + 'journey.studio.photo.edges': 'フチと影', + 'journey.studio.photo.border': 'フチ', + 'journey.studio.photo.width': '幅', + 'journey.studio.photo.shadow': 'くっきりした影', + 'journey.studio.photo.dx': '横', + 'journey.studio.photo.dy': '縦', + 'journey.studio.photo.cutout': '切り抜きのフチ', + 'journey.studio.photo.adjust': '調整', + 'journey.studio.photo.brightness': '明るさ', + 'journey.studio.photo.contrast': 'コントラスト', + 'journey.studio.photo.saturation': '彩度', + 'journey.studio.photo.warmth': '暖かさ', + 'journey.studio.photo.reset': 'リセット', + 'journey.studio.photo.rasterHint': 'フィルターと同じく、ラスター画像として印刷されます。', + 'journey.studio.photo.duotone': 'ダブルトーン', + 'journey.studio.photo.duotoneDark': 'シャドウ', + 'journey.studio.photo.duotoneLight': 'ハイライト', + 'assistant.card.book.finding.raster_effect': '大きな写真のフィルター', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/ko/index.ts +++ b/shared/src/i18n/ko/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ko/studioPhoto.ts b/shared/src/i18n/ko/studioPhoto.ts new file mode 100644 index 000000000..035539397 --- /dev/null +++ b/shared/src/i18n/ko/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': '확대', + 'journey.studio.photo.flip': '좌우 반전', + 'journey.studio.photo.dpi': '약 {dpi} dpi로 인쇄됩니다', + 'journey.studio.photo.dpiLow': '{min} dpi 미만이면 인쇄가 흐릿해 보입니다. 프레임을 줄이거나 축소하세요.', + 'journey.studio.photo.edges': '테두리와 그림자', + 'journey.studio.photo.border': '테두리', + 'journey.studio.photo.width': '너비', + 'journey.studio.photo.shadow': '선명한 그림자', + 'journey.studio.photo.dx': '가로', + 'journey.studio.photo.dy': '세로', + 'journey.studio.photo.cutout': '오려낸 테두리', + 'journey.studio.photo.adjust': '조정', + 'journey.studio.photo.brightness': '밝기', + 'journey.studio.photo.contrast': '대비', + 'journey.studio.photo.saturation': '채도', + 'journey.studio.photo.warmth': '따뜻함', + 'journey.studio.photo.reset': '초기화', + 'journey.studio.photo.rasterHint': '필터처럼 래스터 이미지로 인쇄됩니다.', + 'journey.studio.photo.duotone': '듀오톤', + 'journey.studio.photo.duotoneDark': '어두운 영역', + 'journey.studio.photo.duotoneLight': '밝은 영역', + 'assistant.card.book.finding.raster_effect': '큰 사진의 필터', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/nl/index.ts +++ b/shared/src/i18n/nl/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/nl/studioPhoto.ts b/shared/src/i18n/nl/studioPhoto.ts new file mode 100644 index 000000000..538cd5477 --- /dev/null +++ b/shared/src/i18n/nl/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', // same-as-en + 'journey.studio.photo.flip': 'Spiegelen', + 'journey.studio.photo.dpi': 'Wordt gedrukt op ongeveer {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Onder {min} dpi oogt een afdruk zacht. Maak het kader kleiner of zoom uit.', + 'journey.studio.photo.edges': 'Rand en schaduw', + 'journey.studio.photo.border': 'Rand', + 'journey.studio.photo.width': 'Breedte', + 'journey.studio.photo.shadow': 'Harde schaduw', + 'journey.studio.photo.dx': 'Horizontaal', + 'journey.studio.photo.dy': 'Verticaal', + 'journey.studio.photo.cutout': 'Snijrand', + 'journey.studio.photo.adjust': 'Aanpassen', + 'journey.studio.photo.brightness': 'Helderheid', + 'journey.studio.photo.contrast': 'Contrast', // same-as-en + 'journey.studio.photo.saturation': 'Verzadiging', + 'journey.studio.photo.warmth': 'Warmte', + 'journey.studio.photo.reset': 'Herstellen', + 'journey.studio.photo.rasterHint': 'Wordt als rasterafbeelding gedrukt, net als de filters.', + 'journey.studio.photo.duotone': 'Duotoon', + 'journey.studio.photo.duotoneDark': 'Schaduwen', + 'journey.studio.photo.duotoneLight': 'Hooglichten', + 'assistant.card.book.finding.raster_effect': 'Filters op een grote foto', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/pl/index.ts +++ b/shared/src/i18n/pl/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/pl/studioPhoto.ts b/shared/src/i18n/pl/studioPhoto.ts new file mode 100644 index 000000000..4211c8184 --- /dev/null +++ b/shared/src/i18n/pl/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Powiększenie', + 'journey.studio.photo.flip': 'Odbij lustrzanie', + 'journey.studio.photo.dpi': 'Drukuje się w około {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Poniżej {min} dpi wydruk wygląda miękko. Zmniejsz ramkę albo oddal.', + 'journey.studio.photo.edges': 'Ramka i cień', + 'journey.studio.photo.border': 'Ramka', + 'journey.studio.photo.width': 'Szerokość', + 'journey.studio.photo.shadow': 'Ostry cień', + 'journey.studio.photo.dx': 'W poziomie', + 'journey.studio.photo.dy': 'W pionie', + 'journey.studio.photo.cutout': 'Krawędź wycięcia', + 'journey.studio.photo.adjust': 'Dostosuj', + 'journey.studio.photo.brightness': 'Jasność', + 'journey.studio.photo.contrast': 'Kontrast', + 'journey.studio.photo.saturation': 'Nasycenie', + 'journey.studio.photo.warmth': 'Ciepło', + 'journey.studio.photo.reset': 'Resetuj', + 'journey.studio.photo.rasterHint': 'Drukuje się jako obraz rastrowy, tak jak filtry.', + 'journey.studio.photo.duotone': 'Duoton', + 'journey.studio.photo.duotoneDark': 'Cienie', + 'journey.studio.photo.duotoneLight': 'Światła', + 'assistant.card.book.finding.raster_effect': 'Filtry na dużym zdjęciu', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/ru/index.ts +++ b/shared/src/i18n/ru/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/ru/studioPhoto.ts b/shared/src/i18n/ru/studioPhoto.ts new file mode 100644 index 000000000..b5cce5694 --- /dev/null +++ b/shared/src/i18n/ru/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Масштаб', + 'journey.studio.photo.flip': 'Отразить', + 'journey.studio.photo.dpi': 'Печатается примерно с {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Ниже {min} dpi отпечаток выглядит мягким. Уменьшите рамку или отдалите.', + 'journey.studio.photo.edges': 'Рамка и тень', + 'journey.studio.photo.border': 'Рамка', + 'journey.studio.photo.width': 'Ширина', + 'journey.studio.photo.shadow': 'Жёсткая тень', + 'journey.studio.photo.dx': 'По горизонтали', + 'journey.studio.photo.dy': 'По вертикали', + 'journey.studio.photo.cutout': 'Контур вырезки', + 'journey.studio.photo.adjust': 'Коррекция', + 'journey.studio.photo.brightness': 'Яркость', + 'journey.studio.photo.contrast': 'Контраст', + 'journey.studio.photo.saturation': 'Насыщенность', + 'journey.studio.photo.warmth': 'Теплота', + 'journey.studio.photo.reset': 'Сбросить', + 'journey.studio.photo.rasterHint': 'Печатается как растровое изображение, как и фильтры.', + 'journey.studio.photo.duotone': 'Дуотон', + 'journey.studio.photo.duotoneDark': 'Тени', + 'journey.studio.photo.duotoneLight': 'Света', + 'assistant.card.book.finding.raster_effect': 'Фильтры на большом фото', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/sk/index.ts b/shared/src/i18n/sk/index.ts index 4c107a668..a0d860e09 100644 --- a/shared/src/i18n/sk/index.ts +++ b/shared/src/i18n/sk/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/sk/studioPhoto.ts b/shared/src/i18n/sk/studioPhoto.ts new file mode 100644 index 000000000..c8fae9b96 --- /dev/null +++ b/shared/src/i18n/sk/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Priblíženie', + 'journey.studio.photo.flip': 'Zrkadliť', + 'journey.studio.photo.dpi': 'Tlačí sa zhruba v {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Pod {min} dpi pôsobí tlač mäkko. Zmenšite rámček alebo oddiaľte.', + 'journey.studio.photo.edges': 'Okraj a tieň', + 'journey.studio.photo.border': 'Okraj', + 'journey.studio.photo.width': 'Šírka', + 'journey.studio.photo.shadow': 'Ostrý tieň', + 'journey.studio.photo.dx': 'Vodorovne', + 'journey.studio.photo.dy': 'Zvisle', + 'journey.studio.photo.cutout': 'Vysekaný okraj', + 'journey.studio.photo.adjust': 'Úpravy', + 'journey.studio.photo.brightness': 'Jas', + 'journey.studio.photo.contrast': 'Kontrast', + 'journey.studio.photo.saturation': 'Sýtosť', + 'journey.studio.photo.warmth': 'Teplota', + 'journey.studio.photo.reset': 'Obnoviť', + 'journey.studio.photo.rasterHint': 'Tlačí sa ako rastrový obrázok, rovnako ako filtre.', + 'journey.studio.photo.duotone': 'Duotón', + 'journey.studio.photo.duotoneDark': 'Tiene', + 'journey.studio.photo.duotoneLight': 'Svetlá', + 'assistant.card.book.finding.raster_effect': 'Filtre na veľkej fotke', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/sv/index.ts +++ b/shared/src/i18n/sv/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/sv/studioPhoto.ts b/shared/src/i18n/sv/studioPhoto.ts new file mode 100644 index 000000000..c6219654e --- /dev/null +++ b/shared/src/i18n/sv/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Zoom', // same-as-en + 'journey.studio.photo.flip': 'Spegelvänd', + 'journey.studio.photo.dpi': 'Trycks med ungefär {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Under {min} dpi ser ett tryck mjukt ut. Gör ramen mindre eller zooma ut.', + 'journey.studio.photo.edges': 'Kant och skugga', + 'journey.studio.photo.border': 'Kant', + 'journey.studio.photo.width': 'Bredd', + 'journey.studio.photo.shadow': 'Hård skugga', + 'journey.studio.photo.dx': 'I sidled', + 'journey.studio.photo.dy': 'Nedåt', + 'journey.studio.photo.cutout': 'Stanskant', + 'journey.studio.photo.adjust': 'Justera', + 'journey.studio.photo.brightness': 'Ljusstyrka', + 'journey.studio.photo.contrast': 'Kontrast', + 'journey.studio.photo.saturation': 'Mättnad', + 'journey.studio.photo.warmth': 'Värme', + 'journey.studio.photo.reset': 'Återställ', + 'journey.studio.photo.rasterHint': 'Trycks som rasterbild, precis som filtren.', + 'journey.studio.photo.duotone': 'Duoton', + 'journey.studio.photo.duotoneDark': 'Skuggor', + 'journey.studio.photo.duotoneLight': 'Högdagrar', + 'assistant.card.book.finding.raster_effect': 'Filter på ett stort foto', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/th/index.ts b/shared/src/i18n/th/index.ts index 4c107a668..a0d860e09 100644 --- a/shared/src/i18n/th/index.ts +++ b/shared/src/i18n/th/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/th/studioPhoto.ts b/shared/src/i18n/th/studioPhoto.ts new file mode 100644 index 000000000..21dfed490 --- /dev/null +++ b/shared/src/i18n/th/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'ซูม', + 'journey.studio.photo.flip': 'กลับด้าน', + 'journey.studio.photo.dpi': 'พิมพ์ที่ประมาณ {dpi} dpi', + 'journey.studio.photo.dpiLow': 'ต่ำกว่า {min} dpi งานพิมพ์จะดูไม่คม ลองย่อกรอบหรือซูมออก', + 'journey.studio.photo.edges': 'ขอบและเงา', + 'journey.studio.photo.border': 'ขอบ', + 'journey.studio.photo.width': 'ความกว้าง', + 'journey.studio.photo.shadow': 'เงาคม', + 'journey.studio.photo.dx': 'แนวนอน', + 'journey.studio.photo.dy': 'แนวตั้ง', + 'journey.studio.photo.cutout': 'ขอบตัด', + 'journey.studio.photo.adjust': 'ปรับแต่ง', + 'journey.studio.photo.brightness': 'ความสว่าง', + 'journey.studio.photo.contrast': 'คอนทราสต์', + 'journey.studio.photo.saturation': 'ความอิ่มตัว', + 'journey.studio.photo.warmth': 'ความอุ่น', + 'journey.studio.photo.reset': 'รีเซ็ต', + 'journey.studio.photo.rasterHint': 'พิมพ์เป็นภาพแรสเตอร์ เช่นเดียวกับฟิลเตอร์', + 'journey.studio.photo.duotone': 'ดูโอโทน', + 'journey.studio.photo.duotoneDark': 'เงามืด', + 'journey.studio.photo.duotoneLight': 'ส่วนสว่าง', + 'assistant.card.book.finding.raster_effect': 'ฟิลเตอร์บนรูปภาพขนาดใหญ่', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/tr/index.ts +++ b/shared/src/i18n/tr/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/tr/studioPhoto.ts b/shared/src/i18n/tr/studioPhoto.ts new file mode 100644 index 000000000..b04cff14c --- /dev/null +++ b/shared/src/i18n/tr/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Yakınlaştırma', + 'journey.studio.photo.flip': 'Aynala', + 'journey.studio.photo.dpi': 'Yaklaşık {dpi} dpi ile basılır', + 'journey.studio.photo.dpiLow': '{min} dpi altında baskı yumuşak görünür. Çerçeveyi küçültün ya da uzaklaştırın.', + 'journey.studio.photo.edges': 'Kenarlık ve gölge', + 'journey.studio.photo.border': 'Kenarlık', + 'journey.studio.photo.width': 'Genişlik', + 'journey.studio.photo.shadow': 'Sert gölge', + 'journey.studio.photo.dx': 'Yatay', + 'journey.studio.photo.dy': 'Dikey', + 'journey.studio.photo.cutout': 'Kesim kenarı', + 'journey.studio.photo.adjust': 'Ayarla', + 'journey.studio.photo.brightness': 'Parlaklık', + 'journey.studio.photo.contrast': 'Kontrast', + 'journey.studio.photo.saturation': 'Doygunluk', + 'journey.studio.photo.warmth': 'Sıcaklık', + 'journey.studio.photo.reset': 'Sıfırla', + 'journey.studio.photo.rasterHint': 'Filtreler gibi raster görüntü olarak basılır.', + 'journey.studio.photo.duotone': 'Çift ton', + 'journey.studio.photo.duotoneDark': 'Gölgeler', + 'journey.studio.photo.duotoneLight': 'Parlak tonlar', + 'assistant.card.book.finding.raster_effect': 'Büyük bir fotoğrafta filtreler', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/uk/index.ts +++ b/shared/src/i18n/uk/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/uk/studioPhoto.ts b/shared/src/i18n/uk/studioPhoto.ts new file mode 100644 index 000000000..f673e9caf --- /dev/null +++ b/shared/src/i18n/uk/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Масштаб', + 'journey.studio.photo.flip': 'Віддзеркалити', + 'journey.studio.photo.dpi': 'Друкується приблизно з {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Нижче {min} dpi відбиток виглядає м’яким. Зменште рамку або віддаліть.', + 'journey.studio.photo.edges': 'Рамка і тінь', + 'journey.studio.photo.border': 'Рамка', + 'journey.studio.photo.width': 'Ширина', + 'journey.studio.photo.shadow': 'Жорстка тінь', + 'journey.studio.photo.dx': 'По горизонталі', + 'journey.studio.photo.dy': 'По вертикалі', + 'journey.studio.photo.cutout': 'Контур вирізу', + 'journey.studio.photo.adjust': 'Корекція', + 'journey.studio.photo.brightness': 'Яскравість', + 'journey.studio.photo.contrast': 'Контраст', + 'journey.studio.photo.saturation': 'Насиченість', + 'journey.studio.photo.warmth': 'Теплота', + 'journey.studio.photo.reset': 'Скинути', + 'journey.studio.photo.rasterHint': 'Друкується як растрове зображення, як і фільтри.', + 'journey.studio.photo.duotone': 'Дуотон', + 'journey.studio.photo.duotoneDark': 'Тіні', + 'journey.studio.photo.duotoneLight': 'Світлі ділянки', + 'assistant.card.book.finding.raster_effect': 'Фільтри на великому фото', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/vi/index.ts +++ b/shared/src/i18n/vi/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/vi/studioPhoto.ts b/shared/src/i18n/vi/studioPhoto.ts new file mode 100644 index 000000000..832e65e63 --- /dev/null +++ b/shared/src/i18n/vi/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': 'Thu phóng', + 'journey.studio.photo.flip': 'Lật gương', + 'journey.studio.photo.dpi': 'In ở khoảng {dpi} dpi', + 'journey.studio.photo.dpiLow': 'Dưới {min} dpi bản in trông mềm. Hãy thu nhỏ khung hoặc giảm thu phóng.', + 'journey.studio.photo.edges': 'Viền và bóng', + 'journey.studio.photo.border': 'Viền', + 'journey.studio.photo.width': 'Độ rộng', + 'journey.studio.photo.shadow': 'Bóng cứng', + 'journey.studio.photo.dx': 'Ngang', + 'journey.studio.photo.dy': 'Dọc', + 'journey.studio.photo.cutout': 'Viền cắt', + 'journey.studio.photo.adjust': 'Điều chỉnh', + 'journey.studio.photo.brightness': 'Độ sáng', + 'journey.studio.photo.contrast': 'Độ tương phản', + 'journey.studio.photo.saturation': 'Độ bão hòa', + 'journey.studio.photo.warmth': 'Độ ấm', + 'journey.studio.photo.reset': 'Đặt lại', + 'journey.studio.photo.rasterHint': 'In dưới dạng ảnh raster, giống như các bộ lọc.', + 'journey.studio.photo.duotone': 'Hai tông màu', + 'journey.studio.photo.duotoneDark': 'Vùng tối', + 'journey.studio.photo.duotoneLight': 'Vùng sáng', + 'assistant.card.book.finding.raster_effect': 'Bộ lọc trên một ảnh lớn', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts index 6d7fcbf30..14664b4c7 100644 --- a/shared/src/i18n/zh-TW/index.ts +++ b/shared/src/i18n/zh-TW/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -113,6 +114,7 @@ const locale = { ...todo, ...notifications, ...journey, + ...studioPhoto, ...notif, ...oauth, ...system_notice, diff --git a/shared/src/i18n/zh-TW/studioPhoto.ts b/shared/src/i18n/zh-TW/studioPhoto.ts new file mode 100644 index 000000000..a06cdd22f --- /dev/null +++ b/shared/src/i18n/zh-TW/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': '縮放', + 'journey.studio.photo.flip': '鏡像', + 'journey.studio.photo.dpi': '約以 {dpi} dpi 列印', + 'journey.studio.photo.dpiLow': '低於 {min} dpi 時列印會顯得模糊。請縮小相框或拉遠畫面。', + 'journey.studio.photo.edges': '邊框與陰影', + 'journey.studio.photo.border': '邊框', + 'journey.studio.photo.width': '寬度', + 'journey.studio.photo.shadow': '硬陰影', + 'journey.studio.photo.dx': '水平', + 'journey.studio.photo.dy': '垂直', + 'journey.studio.photo.cutout': '裁切邊', + 'journey.studio.photo.adjust': '調整', + 'journey.studio.photo.brightness': '亮度', + 'journey.studio.photo.contrast': '對比', + 'journey.studio.photo.saturation': '飽和度', + 'journey.studio.photo.warmth': '色溫', + 'journey.studio.photo.reset': '重設', + 'journey.studio.photo.rasterHint': '與濾鏡一樣,以點陣圖像列印。', + 'journey.studio.photo.duotone': '雙色調', + 'journey.studio.photo.duotoneDark': '暗部', + 'journey.studio.photo.duotoneLight': '亮部', + 'assistant.card.book.finding.raster_effect': '大照片上的濾鏡', +}; + +export default studioPhoto; diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts index 69990b155..db8871ef2 100644 --- a/shared/src/i18n/zh/index.ts +++ b/shared/src/i18n/zh/index.ts @@ -58,6 +58,7 @@ import settings from './settings'; import share from './share'; import shared from './shared'; import storage from './storage'; +import studioPhoto from './studioPhoto'; import system_notice from './system_notice'; import todo from './todo'; import tourCollection from './tourCollection'; @@ -114,6 +115,7 @@ const locale = { ...todo, ...notif, ...journey, + ...studioPhoto, ...oauth, ...system_notice, ...transport, diff --git a/shared/src/i18n/zh/studioPhoto.ts b/shared/src/i18n/zh/studioPhoto.ts new file mode 100644 index 000000000..511e27375 --- /dev/null +++ b/shared/src/i18n/zh/studioPhoto.ts @@ -0,0 +1,29 @@ +import type { TranslationStrings } from '../types'; + +/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */ +const studioPhoto: TranslationStrings = { + 'journey.studio.photo.zoom': '缩放', + 'journey.studio.photo.flip': '镜像', + 'journey.studio.photo.dpi': '约以 {dpi} dpi 打印', + 'journey.studio.photo.dpiLow': '低于 {min} dpi 时打印会显得发虚。请缩小相框或缩小画面。', + 'journey.studio.photo.edges': '边框和阴影', + 'journey.studio.photo.border': '边框', + 'journey.studio.photo.width': '宽度', + 'journey.studio.photo.shadow': '硬阴影', + 'journey.studio.photo.dx': '水平', + 'journey.studio.photo.dy': '垂直', + 'journey.studio.photo.cutout': '裁切边', + 'journey.studio.photo.adjust': '调整', + 'journey.studio.photo.brightness': '亮度', + 'journey.studio.photo.contrast': '对比度', + 'journey.studio.photo.saturation': '饱和度', + 'journey.studio.photo.warmth': '色温', + 'journey.studio.photo.reset': '重置', + 'journey.studio.photo.rasterHint': '与滤镜一样,以位图图像打印。', + 'journey.studio.photo.duotone': '双色调', + 'journey.studio.photo.duotoneDark': '暗部', + 'journey.studio.photo.duotoneLight': '亮部', + 'assistant.card.book.finding.raster_effect': '大照片上的滤镜', +}; + +export default studioPhoto; diff --git a/shared/src/index.ts b/shared/src/index.ts index 2fe1b26e8..f40a97d97 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -148,6 +148,8 @@ export * from './book/book-path'; export * from './book/book-icons'; export * from './book/book-map-sources'; export * from './book/book-colour'; +export * from './book/book-photo-look.schema'; +export * from './book/book-photo-look'; export * from './book/book-findings-design'; export * from './book/auto/auto-types'; export * from './book/auto/auto-book';