feat(studio): letters filled with a gradient or a pattern

This commit is contained in:
Maurice
2026-10-24 09:25:28 +02:00
parent 1487ad97f1
commit d17bd47a85
14 changed files with 255 additions and 60 deletions
@@ -64,12 +64,19 @@ export function StudioPaintPicker({
channel,
t,
noneLabel,
kinds = KINDS,
}: {
value: BookPaint | null;
channel: PaintChannel;
t: StudioT;
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
noneLabel: string;
/**
* The kinds offered as tabs; all four by default. With one, the row of tabs
* is left out: the letters of a text pick gradient or pattern beside their
* other fills and only need that kind's controls here.
*/
kinds?: readonly PaintKind[];
}) {
const kind = kindOf(value);
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
@@ -96,28 +103,30 @@ export function StudioPaintPicker({
return (
<div className="st-paint">
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
{KINDS.map((k) => (
<button
type="button"
key={k}
role="tab"
aria-selected={k === kind}
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
onClick={() => choose(k)}
>
<span
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
aria-hidden
{kinds.length > 1 && (
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
{kinds.map((k) => (
<button
type="button"
key={k}
role="tab"
aria-selected={k === kind}
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
onClick={() => choose(k)}
>
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
</span>
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
</button>
))}
</div>
<span
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
aria-hidden
>
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
</span>
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
</button>
))}
</div>
)}
{value?.type === 'solid' && (
<Swatches
@@ -189,6 +189,32 @@ describe('the fill of the letters', () => {
expect(screen.getByText('journey.studio.fx.focalHint')).toBeTruthy();
});
it('turns the letters into a gradient or a pattern from their own colour, edited as a shape is', () => {
const plain = mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' }, color: '#0f766e' }));
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.gradient' }));
const gradient = plain.set.mock.calls.at(-1)?.[0]?.fillPaint;
expect(gradient?.type).toBe('linear');
expect(gradient?.stops[0]?.color).toBe('#0f766e');
fireEvent.click(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }));
expect(plain.set.mock.calls.at(-1)?.[0]?.fillPaint?.type).toBe('pattern');
plain.unmount();
const patterned = {
type: 'pattern' as const,
pattern: 'dots' as const,
fg: '#d62828',
bg: '#ffffff',
scale: 1,
angle: 0,
};
mount(text({ fillPaint: patterned }));
expect(screen.getByRole('radio', { name: 'journey.studio.paint.pattern' }).getAttribute('aria-checked')).toBe(
'true'
);
// Only the pattern's own controls: the kind is picked in the row above, so the paint's tabs stay away.
expect(screen.queryByRole('tablist')).toBeNull();
});
it('says so, and offers no photo fill, when the journey has no photos', () => {
mount(text({ shadow: { dx: 1, dy: 1, color: '#111111' } }), []);
expect(screen.getByText('journey.studio.fx.noPhotos')).toBeTruthy();
@@ -8,13 +8,16 @@ import {
} from 'lucide-react';
import type { BookElement, BookSpread, BookTextElement } from '@trek/shared';
import { bookTextIsDrawn } from '@trek/shared';
import { bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
import '../../styles/studio-text.css';
import { photoSrc } from './bookRender';
import { FocalPad } from './inspector/FocalPad';
import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel';
import { paintAs } from './paint/paintEdit';
import type { PaintChannel } from './paint/usePaintChannel';
import { Choice, Line, NumField, Section, Switch } from './StudioControls';
import { StudioPaintPicker } from './StudioPaintPicker';
import { Swatches } from './StudioSwatches';
import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects';
@@ -275,10 +278,13 @@ function CurveControls({ el, set, t }: Pick<Props, 'el' | 'set' | 't'>) {
);
}
type FillKind = 'colour' | 'hollow' | 'photo';
type FillKind = 'colour' | 'hollow' | 'photo' | 'gradient' | 'pattern';
function fillKindOf(el: BookTextElement): FillKind {
if (el.fillPaint?.type === 'photo') return 'photo';
const type = el.fillPaint?.type;
if (type === 'photo') return 'photo';
if (type === 'linear' || type === 'radial') return 'gradient';
if (type === 'pattern') return 'pattern';
return isHollow(el) ? 'hollow' : 'colour';
}
@@ -294,9 +300,18 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
outline: el.outline ?? { ...defaultOutline(el), color: el.color },
} as Patch);
}
if (next === 'gradient' || next === 'pattern') {
// From the text's own colour, as a shape's paint turns from its solid fill.
return set({ fillPaint: paintAs(next, { type: 'solid', color: el.color }) } as Patch);
}
const photoId = firstFillPhoto(spread, photos);
if (photoId != null) set({ fillPaint: { type: 'photo', photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } } as Patch);
};
const letterPaint = bookTextPaintFill(el);
const channel: PaintChannel = {
live: (paint) => live({ fillPaint: paint } as Patch),
commit: (paint) => set({ fillPaint: paint } as Patch),
};
return (
<div className="st-fx-group">
<Line label={t('journey.studio.fx.letters')}>
@@ -307,11 +322,22 @@ function FillControls({ el, set, live, photos, spread, t }: Props) {
{ value: 'colour' as const, label: t('journey.studio.colour') },
{ value: 'hollow' as const, label: t('journey.studio.fx.hollow') },
{ value: 'photo' as const, label: t('journey.studio.kind.photo'), disabled: !photos.length },
{ value: 'gradient' as const, label: t('journey.studio.paint.gradient') },
{ value: 'pattern' as const, label: t('journey.studio.paint.pattern') },
]}
onPick={pick}
/>
</Line>
{!photos.length && <p className="st-hint st-fx-hint">{t('journey.studio.fx.noPhotos')}</p>}
{letterPaint && (kind === 'gradient' || kind === 'pattern') && (
<StudioPaintPicker
value={letterPaint}
channel={channel}
t={t}
noneLabel={t('journey.studio.colour')}
kinds={[kind]}
/>
)}
{photo && (
<div className="st-fx-body">
<div className="st-fx-photos" role="radiogroup" aria-label={t('journey.studio.fx.pickPhoto')}>
@@ -108,6 +108,40 @@ describe('a text drawn as SVG', () => {
expect(Array.from(clip?.querySelectorAll('tspan') ?? []).map((t) => t.textContent)).toEqual(['LIS', 'BON']);
});
it('fills the letters with a gradient through a gradient of their own frame', () => {
const el = text({
fillPaint: {
type: 'linear',
angle: 90,
stops: [
{ at: 0, color: '#ff6600' },
{ at: 1, color: '#ffcc00' },
],
},
});
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
const gradient = container.querySelector('linearGradient');
expect(gradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
expect(Array.from(gradient?.querySelectorAll('stop') ?? []).map((s) => s.getAttribute('stop-color'))).toEqual([
'#ff6600',
'#ffcc00',
]);
const letters = container.querySelector('svg > text');
expect(letters?.getAttribute('fill')).toBe(`url(#${gradient?.getAttribute('id')})`);
});
it('fills the letters with a pattern, its ground and its marks clipped to them', () => {
const el = text({
fillPaint: { type: 'pattern', pattern: 'stripes', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 },
});
const { container } = render(<TextEffectsView el={el} frameStyle={frameStyle} big={false} />);
const clip = container.getElementsByTagName('clipPath')[0];
const group = container.querySelector('g[clip-path]');
expect(group?.getAttribute('clip-path')).toBe(`url(#${clip?.getAttribute('id')})`);
expect(group?.querySelector('rect')?.getAttribute('fill')).toBe('#ffffff');
expect(group?.querySelectorAll('path[fill="#d62828"]').length).toBeGreaterThan(0);
});
it('gives every copy of a page its own ids, so a page drawn twice never clips to the other copy', () => {
const el = text({ fillPaint: { type: 'photo', photoId: 12 }, curve: { type: 'circle' } });
const { container } = render(
@@ -2,10 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
import { useId } from 'react';
import type { BookTextElement } from '@trek/shared';
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn } from '@trek/shared';
import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn, bookTextPaintFill } from '@trek/shared';
import { fontStack } from './bookFonts';
import { photoSrc } from './bookRender';
import { paintParts } from './paint/paintRender';
import {
caseStyle,
curveAnchor,
@@ -149,11 +150,16 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
const reactId = useId();
const pathId = svgId('tp-', reactId);
const clipId = svgId('tc-', reactId);
const paintId = svgId('tf-', reactId);
const { w, h } = el.frame;
const fontMm = ptToMm(el.size);
const lines = bookDrawnLines(el);
const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null;
const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null;
// A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box.
const fillPaint = bookTextPaintFill(el);
const letterPaint = fillPaint ? paintParts(fillPaint, { x: 0, y: 0, w, h }, paintId) : null;
const pattern = letterPaint?.pattern ?? null;
const hollow = isHollow(el);
const { outline, shadow } = el;
@@ -211,8 +217,20 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
if (photo) {
const cover = photoCover(w, h, photo);
layers.push(<image key="photo" href={photoSrc(photo.photoId, big)} {...cover} clipPath={`url(#${clipId})`} />);
} else if (pattern && letterPaint) {
// The pattern's ground and its marks, clipped to the letters.
layers.push(
<g key="pattern" clipPath={`url(#${clipId})`}>
{letterPaint.fill !== 'none' && <rect width={w} height={h} fill={letterPaint.fill} />}
<g transform={pattern.transform}>
{pattern.layers.map((layer, i) => (
<path key={i} d={layer.d} fill={pattern.ink} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
))}
</g>
</g>
);
} else {
layers.push(glyphs({ fill: letterFill(el) ?? el.color }, 'fill'));
layers.push(glyphs({ fill: letterPaint ? letterPaint.fill : (letterFill(el) ?? el.color) }, 'fill'));
}
return (
@@ -227,7 +245,8 @@ function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: C
>
<defs>
{curve && <path id={pathId} d={curve.d} fill="none" />}
{photo && (
{letterPaint?.defs}
{(photo || pattern) && (
<clipPath id={clipId} clipPathUnits="userSpaceOnUse">
{glyphs(null, 'clip')}
</clipPath>
@@ -42,7 +42,7 @@ function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
}
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
interface PaintParts {
export interface PaintParts {
defs: ReactNode;
fill: string;
fillOpacity?: number;
@@ -50,7 +50,12 @@ interface PaintParts {
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
}
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
/**
* What drawing a paint over a box takes: the gradient definition and the fill
* to point at it, or a pattern's geometry to draw clipped to the outline over
* its ground. A shape's outline and the letters of a text both draw from it.
*/
export function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
switch (paint.type) {
case 'solid':
return {
+1 -1
View File
@@ -28,7 +28,7 @@ export function withAlpha(hex: string, alpha = 1): string {
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.max(0, alpha)})`;
}
/** The colour the letters are filled with, or null when a photograph fills them. */
/** The colour the letters are filled with, or null when a photograph, a gradient or a pattern fills them. */
export function letterFill(el: BookTextElement): string | null {
const fill = el.fillPaint;
if (!fill) return el.color;
+24 -17
View File
@@ -68,32 +68,39 @@ type ObjectFactory = typeof z.object | typeof z.strictObject;
function paintSchemas(obj: ObjectFactory, pattern: z.ZodType<BookPattern, string>) {
const stop = obj({ at: z.number().min(0).max(1), color: hex, alpha: alpha.optional() });
const stops = z.array(stop).min(2).max(MAX_PAINT_STOPS);
const linear = obj({ type: z.literal('linear'), angle, stops });
const radial = obj({
type: z.literal('radial'),
cx: z.number().min(0).max(1),
cy: z.number().min(0).max(1),
r: z.number().min(RADIAL_RADIUS.min).max(RADIAL_RADIUS.max),
stops,
});
const patterned = obj({
type: z.literal('pattern'),
pattern,
fg: hex,
bg: hex.nullable(),
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
angle,
});
const paint = z.discriminatedUnion('type', [
obj({ type: z.literal('solid'), color: hex, alpha: alpha.optional() }),
obj({ type: z.literal('linear'), angle, stops }),
obj({
type: z.literal('radial'),
cx: z.number().min(0).max(1),
cy: z.number().min(0).max(1),
r: z.number().min(RADIAL_RADIUS.min).max(RADIAL_RADIUS.max),
stops,
}),
obj({
type: z.literal('pattern'),
pattern,
fg: hex,
bg: hex.nullable(),
scale: z.number().min(PATTERN_SCALE.min).max(PATTERN_SCALE.max),
angle,
}),
linear,
radial,
patterned,
]);
return { stop, paint };
return { stop, paint, linear, radial, patterned };
}
const docSchemas = paintSchemas(z.object, z.string().pipe(z.enum(BOOK_PATTERNS)));
export const bookPaintStopSchema = docSchemas.stop;
export const bookPaintSchema = docSchemas.paint;
/** The paints that are more than one colour, as the letters of a text take them too (book-text-effects.schema.ts). */
export const bookLinearPaintSchema = docSchemas.linear;
export const bookRadialPaintSchema = docSchemas.radial;
export const bookPatternPaintSchema = docSchemas.patterned;
export type BookPaint = z.infer<typeof bookPaintSchema>;
export type BookPaintStop = z.infer<typeof bookPaintStopSchema>;
export type BookGradientPaint = Extract<BookPaint, { type: 'linear' | 'radial' }>;
+3 -1
View File
@@ -43,8 +43,10 @@ import type { BookDocument } from './book.schema';
* - 9: text effects and fitting (`transform`, `vAlign`, `outline`, `shadow`, `highlight`,
* `curve`, `fillPaint`, `fit`, `fitMax`, book-text-effects.schema.ts), text up to 400 pt and
* tracking up to 1.5 em.
* - 10: letters filled with a gradient or a pattern (`fillPaint` of type `linear`, `radial`,
* `pattern`, book-text-effects.schema.ts).
*/
export const BOOK_SCHEMA_REV = 9;
export const BOOK_SCHEMA_REV = 10;
/** 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';
+9
View File
@@ -10,10 +10,19 @@ const n = (v: number) => `${Math.round(v * 10) / 10}`;
const CASES: Record<string, string> = { upper: 'capitals', lower: 'lower case', smallcaps: 'small caps' };
function stopWords(stops: readonly { color: string }[]): string {
return `from ${stops[0]?.color ?? ''} to ${stops[stops.length - 1]?.color ?? ''}`;
}
function fillWords(fill: NonNullable<BookTextEffects['fillPaint']>): string {
if (fill.type === 'photo') {
return `letters filled with photo ${fill.photoId} (focal ${fill.focalX.toFixed(2)}/${fill.focalY.toFixed(2)}, zoom ${n(fill.zoom)})`;
}
if (fill.type === 'linear') return `letters in a linear gradient at ${n(fill.angle)} deg, ${stopWords(fill.stops)}`;
if (fill.type === 'radial') return `letters in a radial gradient, ${stopWords(fill.stops)}`;
if (fill.type === 'pattern') {
return `letters patterned ${fill.pattern} in ${fill.fg} on ${fill.bg ?? 'clear'} at scale ${n(fill.scale)}`;
}
return fill.alpha === 0 ? 'hollow letters (outline only)' : `fill ${fill.color} at alpha ${n(fill.alpha)}`;
}
@@ -1,4 +1,6 @@
import { bookTextIsDrawn } from './book-text-effects.schema';
import { bookTextEffectWords } from './book-text-describe';
import { bookTextIsDrawn, bookTextPaintFill } from './book-text-effects.schema';
import { bookTextHasOwnGround } from './book-text-findings';
import { bookTextElementSchema } from './book.schema';
import { describe, expect, it } from 'vitest';
@@ -75,3 +77,36 @@ describe('the effect fields of a text', () => {
expect(bookTextIsDrawn(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBe(true);
});
});
describe('letters filled with a gradient or a pattern', () => {
const gradient = {
type: 'linear',
angle: 90,
stops: [
{ at: 0, color: '#ff6600' },
{ at: 1, color: '#ffcc00' },
],
};
const gingham = { type: 'pattern', pattern: 'gingham', fg: '#d62828', bg: '#ffffff', scale: 1, angle: 0 };
it('BKFX-005: reads a gradient and a pattern in the shape a shape takes them, and draws them as SVG', () => {
const lin = parse({ fillPaint: gradient });
const pat = parse({ fillPaint: gingham });
expect(bookTextPaintFill(lin)).toEqual(gradient);
expect(bookTextPaintFill(pat)).toEqual(gingham);
expect(bookTextIsDrawn(lin)).toBe(true);
expect(bookTextIsDrawn(pat)).toBe(true);
expect(bookTextPaintFill(parse({ fillPaint: { type: 'photo', photoId: 3 } }))).toBeNull();
expect(() => parse({ fillPaint: { ...gingham, pattern: 'plaid' } })).toThrow();
});
it('BKFX-006: names the paint for a model that cannot see the page, and judges no contrast against it', () => {
expect(bookTextEffectWords({ ...parse({ fillPaint: gradient }), size: 40 })).toContain(
'linear gradient at 90 deg, from #ff6600 to #ffcc00',
);
expect(bookTextEffectWords({ ...parse({ fillPaint: gingham }), size: 40 })).toContain(
'patterned gingham in #d62828 on #ffffff',
);
expect(bookTextHasOwnGround(parse({ fillPaint: gingham }))).toBe(true);
});
});
+29 -9
View File
@@ -1,3 +1,10 @@
import {
bookLinearPaintSchema,
bookPatternPaintSchema,
bookRadialPaintSchema,
type BookPaint,
} from './book-paint.schema';
import { z } from 'zod';
/**
@@ -27,10 +34,11 @@ import { z } from 'zod';
*
* ── The fill and the paint system ──────────────────────────────────────────
*
* `fillPaint` takes a solid colour with its own alpha or a photograph today.
* Its solid variant has the shape the book's paint type gives a solid fill, so
* gradients and patterns join this union as further members, without a
* reader of today's fields changing.
* `fillPaint` takes a solid colour with its own alpha, a photograph, or one
* of the book's paints of more than one colour: a linear or radial gradient or
* a pattern, in exactly the shape a shape or a page takes them, so one picker
* and one renderer serve all three. A gradient or pattern fill is drawn as SVG
* like a photo fill, the paint clipped to the letters.
*/
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
@@ -84,7 +92,10 @@ export const bookTextCurveSchema = z.object({
});
export type BookTextCurve = z.infer<typeof bookTextCurveSchema>;
/** What fills the letters in place of `color`: a colour with its own alpha (0 leaves an outline only), or a photograph. */
/**
* What fills the letters in place of `color`: a colour with its own alpha (0
* leaves an outline only), a photograph, or a gradient or pattern.
*/
export const bookTextFillSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('solid'), color: hex, alpha: alpha.default(1) }),
z.object({
@@ -95,9 +106,18 @@ export const bookTextFillSchema = z.discriminatedUnion('type', [
focalY: z.number().min(0).max(1).default(0.5),
zoom: z.number().min(1).max(4).default(1),
}),
bookLinearPaintSchema,
bookRadialPaintSchema,
bookPatternPaintSchema,
]);
export type BookTextFill = z.infer<typeof bookTextFillSchema>;
/** The letters' gradient or pattern, as the paint a shape would take, or null for any other fill. */
export function bookTextPaintFill(el: { fillPaint?: BookTextFill | null }): BookPaint | null {
const fill = el.fillPaint;
return fill && (fill.type === 'linear' || fill.type === 'radial' || fill.type === 'pattern') ? fill : null;
}
/** `shrink` sets the text at the largest size up to `fitMax` that holds the frame, solved on edit and stored in `size`. */
export const BOOK_TEXT_FITS = ['none', 'shrink'] as const;
@@ -133,10 +153,10 @@ export interface BookTextEffects {
/**
* Whether a text is drawn as SVG rather than as a box of HTML type: on a
* curve, or filled with a photograph. Such a text is set line by line as
* written, never wrapped, which is what word art wants and what the checks
* measure it by.
* curve, or filled with a photograph, a gradient or a pattern. Such a text is
* set line by line as written, never wrapped, which is what word art wants and
* what the checks measure it by.
*/
export function bookTextIsDrawn(el: BookTextEffects): boolean {
return !!el.curve || el.fillPaint?.type === 'photo';
return !!el.curve || el.fillPaint?.type === 'photo' || !!bookTextPaintFill(el);
}
+2 -1
View File
@@ -30,7 +30,8 @@ const MIN_FILL_PX_PER_MM = 200 / 25.4;
*/
export function bookTextHasOwnGround(el: BookTextElement): boolean {
if (el.highlight) return true;
if (el.fillPaint && (el.fillPaint.type === 'photo' || el.fillPaint.alpha < 0.5)) return true;
// A photo, a gradient or a pattern in the letters is no one colour the page could be judged against.
if (el.fillPaint && (el.fillPaint.type !== 'solid' || el.fillPaint.alpha < 0.5)) return true;
return !!el.outline && el.outline.width >= ptToMm(el.size) * 0.04;
}
+3 -1
View File
@@ -23,7 +23,9 @@ export const TEXT_EFFECT_PROPS = {
shadow: f.shadow.describe('Hard, never blurred; dx, dy in mm.'),
highlight: f.highlight.describe('A marker band behind each line; pads in mm.'),
curve: f.curve.describe('One line on an arc (bend > 0 arches up) or round a circle (start: degrees, 0 top).'),
fillPaint: f.fillPaint.describe('Replaces color: solid (alpha 0 is hollow, with an outline) or a gallery photo.'),
fillPaint: f.fillPaint.describe(
'Replaces color: solid (alpha 0 is hollow, with an outline), a gallery photo, or a linear/radial gradient or pattern shaped as on a shape.',
),
fit: f.fit.describe('shrink: the largest size up to size that holds the frame.'),
};