diff --git a/CHANGELOG.md b/CHANGELOG.md index 91083c3ff..ff59335e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,80 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **Text style carries typographic tracking.** `DocumentTextStyle.builder().letterSpacing(...)` + takes a `DocumentLetterSpacing` — either `ofFontSize(0.12)`, a share of the font size, or + `points(1.2)`, an absolute amount. Negative values tighten. The unit lives in the value + rather than in a bare `double`, because `0.12` and `1.2` are both plausible-looking + numbers and a call site passing one has no way to say which it meant. + + The default is `DocumentLetterSpacing.NONE`, which resolves to zero at every font size, so + a document that never asks for tracking renders exactly as it did. + + **PDF honours it natively.** The advance comes from the PDF `Tc` operator, not from spaces + pushed into the string, so a spaced-caps headline still reads as `JANE DOE` to search, + copy/paste, text extraction and ATS parsers — one glyph per character, the original text. + Tracked runs also state their own text via `ActualText`, because an extractor decides + where words are by how far apart glyphs sit and tracking is the act of moving them apart; + without that statement a widely tracked line comes back as `J A N E D O E` from a file + that is otherwise perfectly correct. + + Measurement and drawing use one rule, measured off PDFBox rather than assumed: one spacing + unit per Unicode **code point** of the string actually drawn, the trailing unit included. + Wrapping, `CENTER`/`RIGHT` alignment, underline and strike rules, link rectangles and + table cells all consume that one measured width, so they follow without special cases. + Negative tracking tightens, and the measured width is not clamped — the pen really does + move backwards, and a measurement that refused to would simply stop matching the page. + + **All three backends carry it natively.** PPTX writes DrawingML's `spc` in hundredths of + a point, DOCX writes Word's run-level `w:spacing` in twentieths, and neither pads the + text. The units and the advance rule were measured rather than read off the + specification: probe files were exported to PDF by PowerPoint and Word themselves and the + glyph positions read back. Both applications spend the spacing the way PDF's `Tc` does — + one unit per code point, the trailing one included, an ordinary space counted like any + other character. + + **Tracking has a granularity, and it is 0.01pt in a fixed-layout document.** DrawingML can + only state hundredths of a point, so that is the finest distinction a PDF and a deck can + both make. The engine measures on that grid rather than on the raw value, which is what + keeps the width it reserves, wraps against and aligns to the width the file will actually + draw: ask for a third of a point and every fixed backend, and the measurement behind them, + uses 0.33. Word's own grid is coarser still at 0.05pt, and the DOCX export rounds the + authored value to it independently — a semantic document owes the fixed backends no + coordinate. The authored `DocumentLetterSpacing` is never rewritten; it keeps the value + and the unit it was given, and reports them back unchanged. + + A tracking too large for a format to state is refused rather than silently wrapped — + beyond ±4000pt for fixed layout, which is where DrawingML's own bound sits. + + Asking for no tracking writes nothing at all: no `spc` attribute, no `w:spacing` element, + no `Tc` operator. Every existing document is byte-for-byte what it was. + +- **The built-in CV and cover-letter presets now use real tracking, so their text is + readable again.** Spaced caps in those presets were drawn by rewriting the string with a + space between every pair of letters. The page looked right and the file did not: an + applicant's name was stored as `J A N E D O E`, which is the one field a CV is searched + and parsed by. All 33 call sites are migrated — the name, the job title, section banners, + skill labels, education headings — and the text in the file is now the text that was + typed, in PDF, PPTX and DOCX alike. + + **Headings also stop breaking mid-word.** Padding every letter out made each letter its + own word to the line breaker, so a heading wrapped wherever it ran out of room: + `EDUCATION & CERT` / `IFICATIONS`, `ORACLE JAVA CERTIFICAT` / `ION`. Words are whole + again, so they wrap between words. + + The tracking is `ofFontSize(0.18)`, one value for every preset, chosen by measuring what + the old transform produced: a space glyph between letters is 0.232–0.278 em in the faces + these presets use, and matching the old *total* width — real tracking adds a unit after + the last glyph and to the word space as well — puts the equivalent at 0.174–0.209 em. + Headings therefore occupy close to the width they did. Expect small visual differences on + the presets that use spaced caps; nine of the sixteen CV presets move, all by under 2% of + the page. + + No built-in preset calls `TextOrnaments.spacedUpper` any more, and the two private copies + of it that had grown in `SidebarPortrait` and `TimelineMinimal` are gone. The public + method itself stays, unchanged, and is deprecated — see **Deprecations** below. + `TextOrnaments.upper` is the replacement and does only what its name says. + - **A list can hang its wrapped lines under its own text instead of under its marker.** `ListBuilder.hangingIndent(true)` gives an item a marker column and a content column, so every visual line of it starts at one horizontal position — the first line, the lines it @@ -469,6 +543,23 @@ follow semantic versioning; release dates are ISO 8601. constructions of one 16pt marker; an outline of any thickness; and the rail painted before the text and not only before the markers. +### Deprecations + +- **`TextOrnaments.spacedUpper(String)`** is `@Deprecated(since = "2.4.0", forRemoval = true)`. + It is not removed, and its behaviour has not changed by a single character — code written + against 2.3.0 keeps compiling and keeps getting the same strings back. What changed is that + no built-in preset calls it: they set `TextOrnaments.SPACED_CAPS` on the style instead, so + the letters are spread by the typography rather than by rewriting the text. + + New code should do the same — `TextOrnaments.upper(...)` for the text, and `SPACED_CAPS` + or any `DocumentLetterSpacing` on the style. The reason to migrate is not tidiness: padding + the string is what stored a name in the file as `J A N E D O E`, which is how it reached + search, copy/paste, screen readers and applicant-tracking parsers. Expect the same look at a + slightly different width — a whole space glyph per gap is wider than editorial tracking. + + Per [`docs/api-stability.md`](docs/api-stability.md) § 3 it is Stable-tier, so it is removed + no earlier than 3.0 and not before a full minor has shipped with the deprecation in place. + ### Documentation - **The timeline recipe describes the finished model.** `LEADING | AXIS | CONTENT`, what the diff --git a/assets/readme/examples/cover-letter-blue-banner-v2.pdf b/assets/readme/examples/cover-letter-blue-banner-v2.pdf index b1b0cdfb4..75b3c51d0 100644 Binary files a/assets/readme/examples/cover-letter-blue-banner-v2.pdf and b/assets/readme/examples/cover-letter-blue-banner-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-boxed-sections-v2.pdf b/assets/readme/examples/cover-letter-boxed-sections-v2.pdf index 80f71506d..15bb3d355 100644 Binary files a/assets/readme/examples/cover-letter-boxed-sections-v2.pdf and b/assets/readme/examples/cover-letter-boxed-sections-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-centered-headline-v2.pdf b/assets/readme/examples/cover-letter-centered-headline-v2.pdf index 4676f0702..b716d4bb4 100644 Binary files a/assets/readme/examples/cover-letter-centered-headline-v2.pdf and b/assets/readme/examples/cover-letter-centered-headline-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-classic-serif-v2.pdf b/assets/readme/examples/cover-letter-classic-serif-v2.pdf index 39ff17854..778fe9e2f 100644 Binary files a/assets/readme/examples/cover-letter-classic-serif-v2.pdf and b/assets/readme/examples/cover-letter-classic-serif-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-mint-editorial-v2.pdf b/assets/readme/examples/cover-letter-mint-editorial-v2.pdf index 009d24056..bc2b94892 100644 Binary files a/assets/readme/examples/cover-letter-mint-editorial-v2.pdf and b/assets/readme/examples/cover-letter-mint-editorial-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf b/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf index b94e793a0..892a13782 100644 Binary files a/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf and b/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf b/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf index 9b0b1c453..b3374c5f1 100644 Binary files a/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf and b/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf b/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf index b63afff4c..dfc57099f 100644 Binary files a/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf and b/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf differ diff --git a/assets/readme/examples/cv-blue-banner-v2.pdf b/assets/readme/examples/cv-blue-banner-v2.pdf index 2a7d6546a..eaf9a6052 100644 Binary files a/assets/readme/examples/cv-blue-banner-v2.pdf and b/assets/readme/examples/cv-blue-banner-v2.pdf differ diff --git a/assets/readme/examples/cv-boxed-sections-v2.pdf b/assets/readme/examples/cv-boxed-sections-v2.pdf index c1e6de923..a2f8fb368 100644 Binary files a/assets/readme/examples/cv-boxed-sections-v2.pdf and b/assets/readme/examples/cv-boxed-sections-v2.pdf differ diff --git a/assets/readme/examples/cv-centered-headline-v2.pdf b/assets/readme/examples/cv-centered-headline-v2.pdf index 940f7ef7b..ae1f63cdb 100644 Binary files a/assets/readme/examples/cv-centered-headline-v2.pdf and b/assets/readme/examples/cv-centered-headline-v2.pdf differ diff --git a/assets/readme/examples/cv-classic-serif-v2.pdf b/assets/readme/examples/cv-classic-serif-v2.pdf index 4c964d757..19dde156c 100644 Binary files a/assets/readme/examples/cv-classic-serif-v2.pdf and b/assets/readme/examples/cv-classic-serif-v2.pdf differ diff --git a/assets/readme/examples/cv-minimal-underlined-v2.pdf b/assets/readme/examples/cv-minimal-underlined-v2.pdf index 24688d0c8..c4d1082cf 100644 Binary files a/assets/readme/examples/cv-minimal-underlined-v2.pdf and b/assets/readme/examples/cv-minimal-underlined-v2.pdf differ diff --git a/assets/readme/examples/cv-mint-editorial-v2-custom.pdf b/assets/readme/examples/cv-mint-editorial-v2-custom.pdf index 39017cb7f..993262500 100644 Binary files a/assets/readme/examples/cv-mint-editorial-v2-custom.pdf and b/assets/readme/examples/cv-mint-editorial-v2-custom.pdf differ diff --git a/assets/readme/examples/cv-mint-editorial-v2.pdf b/assets/readme/examples/cv-mint-editorial-v2.pdf index 75074661e..e4d327e40 100644 Binary files a/assets/readme/examples/cv-mint-editorial-v2.pdf and b/assets/readme/examples/cv-mint-editorial-v2.pdf differ diff --git a/assets/readme/examples/cv-monogram-sidebar-v2.pdf b/assets/readme/examples/cv-monogram-sidebar-v2.pdf index 8c5a326ec..bbd3951d5 100644 Binary files a/assets/readme/examples/cv-monogram-sidebar-v2.pdf and b/assets/readme/examples/cv-monogram-sidebar-v2.pdf differ diff --git a/assets/readme/examples/cv-sidebar-portrait-v2.pdf b/assets/readme/examples/cv-sidebar-portrait-v2.pdf index 2ce4058ef..5f79c49f8 100644 Binary files a/assets/readme/examples/cv-sidebar-portrait-v2.pdf and b/assets/readme/examples/cv-sidebar-portrait-v2.pdf differ diff --git a/assets/readme/examples/cv-timeline-minimal-v2.pdf b/assets/readme/examples/cv-timeline-minimal-v2.pdf index 7ac32293c..c9124479f 100644 Binary files a/assets/readme/examples/cv-timeline-minimal-v2.pdf and b/assets/readme/examples/cv-timeline-minimal-v2.pdf differ diff --git a/assets/readme/examples/letter-spacing.pdf b/assets/readme/examples/letter-spacing.pdf new file mode 100644 index 000000000..56b06d7a1 Binary files /dev/null and b/assets/readme/examples/letter-spacing.pdf differ diff --git a/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java b/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java index a6925e1ad..2bb36301d 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java +++ b/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java @@ -58,7 +58,78 @@ static TextStyle toTextStyle(DocumentTextStyle textStyle) { textStyle.fontName(), textStyle.size(), toDecoration(textStyle.decoration()), - textStyle.color().color()); + textStyle.color().color(), + // The public value keeps its unit; the engine gets points. This + // is the only place that knows the font size and the unit at + // the same time, so it is the only place that can resolve one + // against the other. + toFixedLayoutTracking(textStyle.letterSpacing().resolve(textStyle.size()))); + } + + /** + * The largest tracking a fixed-layout document can carry, in points. + * + *

Set by DrawingML, the least capacious of the fixed backends: {@code spc} + * is {@code ST_TextPoint}, whose numeric member is bounded at + * ±400000 hundredths. Measured, not read — the schema validates + * {@code 400000} and rejects {@code 400001}.

+ */ + static final double MAX_FIXED_LAYOUT_TRACKING_POINTS = 4000.0; + + /** + * Tracking as fixed layout can actually express it: quantised to hundredths + * of a point. + * + *

This exists because the engine's measurement and the file's declared + * spacing have to be the same number, and PPTX can only declare + * hundredths. Left unquantised, a {@code points(1.0/3.0)} style measured at + * {@code 0.33333…} per code point while the deck said {@code spc="33"} — + * {@code 0.33} — so the width the layout reserved, wrapped against, aligned + * to and sized its frames from was a width the deck would never draw. The + * residue is small per code point and accumulates with the string: a third + * of a point is {@code 0.0033} out per code point, {@code 0.13pt} over a + * forty-character line. Quantising here makes the engine measure the value + * every fixed backend will actually use, so PDF's {@code Tc} and PPTX's + * {@code spc} are two spellings of one number.

+ * + *

It is done once, here, rather than in each backend: this is the single + * seam where the public value becomes engine points, so it is the only place + * that can make the measurement and every renderer agree by construction. + * The public {@link DocumentTextStyle} is untouched — it still carries + * exactly what the author wrote, and {@code DocumentLetterSpacing} still + * resolves to exactly what the author asked for. The quantisation is a + * property of fixed layout, not of the value.

+ * + *

The semantic DOCX export does not come through here. It resolves the + * public value itself and rounds to Word's twentieths, which is a coarser + * grid again — and correctly so, because Word owns that layout and + * owes the PDF no coordinate.

+ * + *

Out of range is refused rather than clamped or wrapped. {@code spc} is + * written as an {@code int} of hundredths, and a large enough value silently + * changes sign on the cast — {@code 2.2e7} points becomes + * {@code -2094967296}, turning wide tracking into tight. A document asking + * for more than the format can hold is a mistake worth hearing about.

+ * + * @param points resolved tracking in points + * @return the same tracking on the grid fixed layout can express + * @throws IllegalArgumentException if the tracking exceeds + * {@link #MAX_FIXED_LAYOUT_TRACKING_POINTS} + */ + private static double toFixedLayoutTracking(double points) { + if (points == 0.0) { + // Short-circuited so an untracked style keeps the identical double, + // and never depends on the rounding below behaving at zero. + return 0.0; + } + if (Math.abs(points) > MAX_FIXED_LAYOUT_TRACKING_POINTS) { + throw new IllegalArgumentException( + "Letter spacing resolves to " + points + "pt, beyond the " + + MAX_FIXED_LAYOUT_TRACKING_POINTS + + "pt a fixed-layout document can express (DrawingML spc is " + + "hundredths of a point, bounded at +/-400000)."); + } + return Math.round(points * 100.0) / 100.0; } static TextIndentStrategy toIndentStrategy(DocumentTextIndent indent) { diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java new file mode 100644 index 000000000..9bf40ebf7 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java @@ -0,0 +1,160 @@ +package com.demcha.compose.document.style; + +/** + * Letter spacing (typographic tracking) for a + * {@link DocumentTextStyle} — extra advance inserted after every rendered + * code point, expressed either in absolute points or as a fraction of the font + * size. + * + *

This is real tracking, not spaces: the string handed to the backend is the + * author's string, so the PDF text layer, search, copy/paste, text extraction + * and ATS parsing all still see {@code "JANE DOE"} for a headline that renders + * as widely spaced caps. Padding the string with literal spaces — the + * shape {@code "J A N E D O E"} — achieves the same picture and breaks + * every one of those.

+ * + *

Prefer {@link #ofFontSize(double)}: expressed as a fraction, the tracking + * scales with the type, so one style value reads the same at 9pt as at 24pt and + * keeps its proportions under auto-size. {@link #points(double)} is there for + * the cases that are specified in absolute points. The unit lives in the value, + * so a call site says which one it meant — {@code ofFontSize(0.12)} and + * {@code points(1.2)} are both plausible-looking numbers and a bare + * {@code double} could not tell them apart.

+ * + *

Negative tracking (tighter than normal) is allowed. {@link #NONE} is the + * neutral value carried by every style that has not opted in; it resolves to + * {@code 0} and leaves measurement and rendering exactly as they were. + * Instances are immutable and thread-safe.

+ * + *

What survives into a file

+ * + *

This value keeps exactly what it was given: {@link #resolve(double)} + * returns the amount asked for, to the last bit, and nothing rewrites it. The + * file formats are what quantise, and they do it differently:

+ * + * + * + *

So an arbitrary {@code double} does not survive all three formats exactly, + * and no amount of care here would make it. What is guaranteed is that within + * fixed layout there is one number: what was measured, what the PDF states and + * what the deck states are the same value.

+ * + *

Tracking larger than a format can state is refused when the document is + * rendered, rather than wrapped into a negative — fixed layout tops out at + * ±4000pt, DrawingML's own bound. The limits belong to the formats; this + * value accepts any finite number.

+ * + *
{@code
+ * DocumentTextStyle headline = DocumentTextStyle.builder()
+ *         .size(24)
+ *         .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12)) // 12% of 24pt = 2.88pt
+ *         .build();
+ * }
+ * + * @param type whether {@code value} is read as points or as a fraction of the + * font size + * @param value the tracking amount, in the unit named by {@code type} + * @author Artem Demchyshyn + * @see DocumentTextStyle#letterSpacing() + * @since 2.4.0 + */ +public record DocumentLetterSpacing(Type type, double value) { + + /** The unit a tracking amount is expressed in. */ + public enum Type { + /** {@code value} is an absolute amount in points. */ + POINTS, + /** {@code value} is a fraction of the font size (an em share). */ + FONT_SIZE + } + + /** + * No tracking — the neutral value, and the default of every + * {@link DocumentTextStyle}. Resolves to {@code 0} at any font size. + */ + public static final DocumentLetterSpacing NONE = new DocumentLetterSpacing(Type.POINTS, 0.0); + + /** + * Validates the unit and the amount. + * + * @param type the unit; must not be {@code null} + * @param value the amount; must be finite, may be negative + */ + public DocumentLetterSpacing { + if (type == null) { + throw new IllegalArgumentException("Letter-spacing type cannot be null."); + } + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("Letter spacing must be a finite number, got: " + value); + } + // -0.0 renders identically to +0.0 but would compare unequal to it and + // hash differently, so it is folded. Note this does not make zero a + // single value: the factories return NONE for it, but + // new DocumentLetterSpacing(FONT_SIZE, 0.0) is still constructible and + // is not equal(NONE) — same behaviour, different unit, and the unit is + // the caller's to state. + value = value == 0.0 ? 0.0 : value; + } + + /** + * Tracking of an absolute size, in points. + * + * @param points extra advance after each code point, in points; negative + * tightens, {@code 0} is {@link #NONE} + * @return a points-valued tracking + */ + public static DocumentLetterSpacing points(double points) { + return points == 0.0 ? NONE : new DocumentLetterSpacing(Type.POINTS, points); + } + + /** + * Tracking as a fraction of the font size, so it scales with the type. + * + * @param fraction share of the font size, e.g. {@code 0.12} for 12%; + * negative tightens, {@code 0} is {@link #NONE} + * @return a font-size-relative tracking + */ + public static DocumentLetterSpacing ofFontSize(double fraction) { + return fraction == 0.0 ? NONE : new DocumentLetterSpacing(Type.FONT_SIZE, fraction); + } + + /** + * Resolves this tracking to points against a concrete font size. + * + *

A non-finite {@code fontSize} makes the tracking contribution + * {@code 0} instead of {@code NaN}. That bounds this term only — it says + * nothing about the rest of the measurement, which still multiplies glyph + * widths by that same font size. A non-finite font size remains a bad font + * size, and it is not this type's job to make it finite.

+ * + * @param fontSize the font size the text is set at, in points + * @return the extra advance per code point, in points + */ + public double resolve(double fontSize) { + return switch (type) { + case POINTS -> value; + case FONT_SIZE -> Double.isFinite(fontSize) ? value * fontSize : 0.0; + }; + } + + /** + * Whether this is the neutral value, i.e. it resolves to {@code 0} at every + * font size. + * + * @return {@code true} if no tracking is applied + */ + public boolean isNone() { + return value == 0.0; + } +} diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentTextStyle.java b/core/src/main/java/com/demcha/compose/document/style/DocumentTextStyle.java index 30ba09bcc..d517ffff0 100644 --- a/core/src/main/java/com/demcha/compose/document/style/DocumentTextStyle.java +++ b/core/src/main/java/com/demcha/compose/document/style/DocumentTextStyle.java @@ -18,13 +18,17 @@ * @param size font size in points * @param decoration text decoration * @param color text color + * @param letterSpacing typographic tracking; {@link DocumentLetterSpacing#NONE} + * (the default) leaves glyph advances exactly as they were * @author Artem Demchyshyn + * @see DocumentLetterSpacing */ public record DocumentTextStyle( FontName fontName, double size, DocumentTextDecoration decoration, - DocumentColor color + DocumentColor color, + DocumentLetterSpacing letterSpacing ) { public static final DocumentTextStyle DEFAULT = builder().build(); @@ -36,6 +40,27 @@ public record DocumentTextStyle( size = size <= 0 ? 14 : size; decoration = decoration == null ? DocumentTextDecoration.DEFAULT : decoration; color = color == null ? DocumentColor.BLACK : color; + letterSpacing = letterSpacing == null ? DocumentLetterSpacing.NONE : letterSpacing; + } + + /** + * Creates a normalized text style without tracking. + * + *

The signature this type carried before {@code letterSpacing} was + * added. It stays so code compiled against the 2.0.0 surface keeps + * linking, and so the binary-compatibility gate still finds the + * constructor it has always found.

+ * + * @param fontName font family name + * @param size font size in points + * @param decoration text decoration + * @param color text color + */ + public DocumentTextStyle(FontName fontName, + double size, + DocumentTextDecoration decoration, + DocumentColor color) { + this(fontName, size, decoration, color, DocumentLetterSpacing.NONE); } /** @@ -54,7 +79,7 @@ public static Builder builder() { * @return updated text style */ public DocumentTextStyle withSize(double size) { - return new DocumentTextStyle(fontName, size, decoration, color); + return new DocumentTextStyle(fontName, size, decoration, color, letterSpacing); } /** @@ -64,7 +89,19 @@ public DocumentTextStyle withSize(double size) { * @return updated text style */ public DocumentTextStyle withColor(DocumentColor color) { - return new DocumentTextStyle(fontName, size, decoration, color); + return new DocumentTextStyle(fontName, size, decoration, color, letterSpacing); + } + + /** + * Creates a copy with different tracking. + * + * @param letterSpacing tracking to apply; {@code null} means + * {@link DocumentLetterSpacing#NONE} + * @return updated text style + * @since 2.4.0 + */ + public DocumentTextStyle withLetterSpacing(DocumentLetterSpacing letterSpacing) { + return new DocumentTextStyle(fontName, size, decoration, color, letterSpacing); } /** @@ -75,6 +112,7 @@ public static final class Builder { private double size = 14; private DocumentTextDecoration decoration = DocumentTextDecoration.DEFAULT; private DocumentColor color = DocumentColor.BLACK; + private DocumentLetterSpacing letterSpacing = DocumentLetterSpacing.NONE; private Builder() { } @@ -123,13 +161,33 @@ public Builder color(DocumentColor color) { return this; } + /** + * Sets the typographic tracking — extra advance after every + * rendered code point. + * + *

Real tracking, not inserted spaces: the text handed to the + * backend stays the author's string, so search, copy/paste and text + * extraction still read it as written.

+ * + * @param letterSpacing tracking to apply, e.g. + * {@code DocumentLetterSpacing.ofFontSize(0.12)}; + * {@code null} means + * {@link DocumentLetterSpacing#NONE} + * @return this builder + * @since 2.4.0 + */ + public Builder letterSpacing(DocumentLetterSpacing letterSpacing) { + this.letterSpacing = Objects.requireNonNullElse(letterSpacing, DocumentLetterSpacing.NONE); + return this; + } + /** * Builds an immutable style value. * * @return text style */ public DocumentTextStyle build() { - return new DocumentTextStyle(fontName, size, decoration, color); + return new DocumentTextStyle(fontName, size, decoration, color, letterSpacing); } } } diff --git a/core/src/main/java/com/demcha/compose/engine/components/content/text/TextStyle.java b/core/src/main/java/com/demcha/compose/engine/components/content/text/TextStyle.java index b422f95d0..1b673a22e 100644 --- a/core/src/main/java/com/demcha/compose/engine/components/content/text/TextStyle.java +++ b/core/src/main/java/com/demcha/compose/engine/components/content/text/TextStyle.java @@ -5,8 +5,40 @@ import java.awt.*; +/** + * Engine-side text style. + * + *

{@code letterSpacing} is the tracking already resolved to points: + * the public {@code DocumentLetterSpacing} keeps its unit (points or a share of + * the font size) and the single conversion seam resolves it, so nothing below + * this type has to remember to multiply by the font size.

+ * + * @param fontName font family name + * @param size font size in points + * @param decoration text decoration + * @param color text color + * @param letterSpacing tracking in points, already resolved; {@code 0} for none + */ @Builder -public record TextStyle(FontName fontName, double size, TextDecoration decoration, Color color) { +public record TextStyle(FontName fontName, + double size, + TextDecoration decoration, + Color color, + double letterSpacing) { public static TextStyle DEFAULT_STYLE = new TextStyle(FontName.HELVETICA, 14, TextDecoration.DEFAULT, Color.BLACK); + + /** + * Creates a style without tracking — the shape this record had before + * {@code letterSpacing} was added, kept so the existing engine call sites + * that build a style from four values stay as they are. + * + * @param fontName font family name + * @param size font size in points + * @param decoration text decoration + * @param color text color + */ + public TextStyle(FontName fontName, double size, TextDecoration decoration, Color color) { + this(fontName, size, decoration, color, 0.0); + } } diff --git a/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java b/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java index 7996d98a1..9d07a4d09 100644 --- a/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java +++ b/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java @@ -27,7 +27,7 @@ public List getBody(String markdown, TextStyle style) { // 1) List items: add your own prefix (since '-' is not Text) new VisitHandler<>(ListItem.class, node -> { TextStyle prefixStyle = new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, - style.color()); + style.color(), style.letterSpacing()); // New line before each list item (optional; helps readability) // resultList.add(new TextDataBody("\n", prefixStyle)); @@ -43,10 +43,12 @@ public List getBody(String markdown, TextStyle style) { // 2) Preserve line breaks new VisitHandler<>(SoftLineBreak.class, br -> resultList.add(new TextDataBody(" ", - new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, style.color())))), + new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, style.color(), + style.letterSpacing())))), new VisitHandler<>(HardLineBreak.class, br -> resultList.add(new TextDataBody(" ", - new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, style.color())))), + new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, style.color(), + style.letterSpacing())))), // 3) Headers new VisitHandler<>(Heading.class, node -> { @@ -59,7 +61,18 @@ public List getBody(String markdown, TextStyle style) { }; double newSize = style.size() * scale; TextStyle headerStyle = new TextStyle(style.fontName(), newSize, TextDecoration.BOLD, - style.color()); + // Tracking is carried across unchanged, deliberately. + // Scaling it with the heading breaks two things: the + // value arrives already quantised to the hundredth of a + // point that DrawingML can state, and 0.33 x 1.5 is + // 0.495 — which the engine would measure and PPTX would + // have to round back to 0.50, reopening the very gap + // between measurement and file the quantisation exists + // to close. It would also scale an absolute points(1.2) + // that was never meant to follow the font size, and by + // here the unit is gone, so the two cannot be told + // apart. + style.color(), style.letterSpacing()); // Add newline before header for better separation // resultList.add(new TextDataBody("\n", @@ -102,7 +115,8 @@ public List getBody(String markdown, TextStyle style) { // 4) Text nodes (your current logic) new VisitHandler<>(Text.class, textNode -> { TextDecoration decoration = determineStyle(textNode); - TextStyle newTextStyle = new TextStyle(style.fontName(), style.size(), decoration, style.color()); + TextStyle newTextStyle = new TextStyle(style.fontName(), style.size(), decoration, style.color(), + style.letterSpacing()); String rawText = textNode.getChars().toString(); splitKeepingWhitespace(rawText).stream() diff --git a/core/src/test/java/com/demcha/compose/document/layout/LetterSpacingPropagationTest.java b/core/src/test/java/com/demcha/compose/document/layout/LetterSpacingPropagationTest.java new file mode 100644 index 000000000..c75403a02 --- /dev/null +++ b/core/src/test/java/com/demcha/compose/document/layout/LetterSpacingPropagationTest.java @@ -0,0 +1,103 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentLetterSpacing; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.engine.components.content.text.TextStyle; +import com.demcha.compose.font.FontName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tracking crossing the one seam between the public style and the engine style. + * + *

The public value keeps its unit; the engine is handed points. This is the + * only place that knows the unit and the font size at the same time, so it is + * the only place that can resolve one against the other — and the only + * place that has to be right about it.

+ */ +class LetterSpacingPropagationTest { + + @Test + void aStyleWithoutTrackingReachesTheEngineAsZero() { + TextStyle engineStyle = DocumentNodeAdapters.toTextStyle(DocumentTextStyle.DEFAULT); + + assertThat(engineStyle.letterSpacing()).isZero(); + } + + @Test + void aNullStyleFallsBackToTheEngineDefaultWhichHasNoTracking() { + assertThat(DocumentNodeAdapters.toTextStyle(null).letterSpacing()).isZero(); + assertThat(TextStyle.DEFAULT_STYLE.letterSpacing()).isZero(); + } + + @Test + void pointsCrossTheSeamUnchanged() { + DocumentTextStyle style = DocumentTextStyle.builder() + .size(24) + .letterSpacing(DocumentLetterSpacing.points(1.2)) + .build(); + + assertThat(DocumentNodeAdapters.toTextStyle(style).letterSpacing()).isEqualTo(1.2); + } + + @Test + void aFontSizeShareIsResolvedAgainstTheStylesOwnSizeBeforeTheEngineSeesIt() { + DocumentTextStyle style = DocumentTextStyle.builder() + .size(24) + .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12)) + .build(); + + // 12% of 24pt. The engine never learns the share existed. + assertThat(DocumentNodeAdapters.toTextStyle(style).letterSpacing()).isEqualTo(2.88); + } + + @Test + void theShareIsResolvedAgainstTheNormalizedSizeNotTheRequestedOne() { + // DocumentTextStyle folds a non-positive size onto 14pt; the tracking + // has to follow the size the text is actually set at. + DocumentTextStyle style = DocumentTextStyle.builder() + .size(0) + .letterSpacing(DocumentLetterSpacing.ofFontSize(0.5)) + .build(); + + assertThat(style.size()).isEqualTo(14.0); + assertThat(DocumentNodeAdapters.toTextStyle(style).letterSpacing()).isEqualTo(7.0); + } + + @Test + void negativeTrackingSurvivesTheCrossing() { + DocumentTextStyle style = DocumentTextStyle.builder() + .size(20) + .letterSpacing(DocumentLetterSpacing.ofFontSize(-0.05)) + .build(); + + assertThat(DocumentNodeAdapters.toTextStyle(style).letterSpacing()).isEqualTo(-1.0); + } + + @Test + void everyOtherComponentStillCrossesAsItDid() { + DocumentTextStyle style = new DocumentTextStyle( + FontName.TIMES_ROMAN, 18, DocumentTextDecoration.BOLD, DocumentColor.rgb(17, 34, 51), + DocumentLetterSpacing.points(0.75)); + + TextStyle engineStyle = DocumentNodeAdapters.toTextStyle(style); + + assertThat(engineStyle.fontName()).isEqualTo(FontName.TIMES_ROMAN); + assertThat(engineStyle.size()).isEqualTo(18.0); + assertThat(engineStyle.color()).isEqualTo(DocumentColor.rgb(17, 34, 51).color()); + assertThat(engineStyle.letterSpacing()).isEqualTo(0.75); + } + + @Test + void theEngineStylesFourArgumentShapeStillMeansNoTracking() { + TextStyle style = new TextStyle( + FontName.HELVETICA, 12, + com.demcha.compose.engine.components.content.text.TextDecoration.DEFAULT, + java.awt.Color.BLACK); + + assertThat(style.letterSpacing()).isZero(); + } +} diff --git a/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java b/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java new file mode 100644 index 000000000..7fa07bccf --- /dev/null +++ b/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java @@ -0,0 +1,100 @@ +package com.demcha.compose.document.style; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.within; + +/** + * The tracking value itself: what it accepts, what it refuses, and how it + * resolves against the size the text is actually set at. + */ +class DocumentLetterSpacingTest { + + @Test + void noneResolvesToZeroAtEverySize() { + assertThat(DocumentLetterSpacing.NONE.isNone()).isTrue(); + assertThat(DocumentLetterSpacing.NONE.resolve(9)).isZero(); + assertThat(DocumentLetterSpacing.NONE.resolve(24)).isZero(); + assertThat(DocumentLetterSpacing.NONE.resolve(0)).isZero(); + } + + @Test + void pointsResolveToThemselvesRegardlessOfFontSize() { + DocumentLetterSpacing spacing = DocumentLetterSpacing.points(1.2); + + assertThat(spacing.type()).isEqualTo(DocumentLetterSpacing.Type.POINTS); + assertThat(spacing.resolve(9)).isEqualTo(1.2); + assertThat(spacing.resolve(24)).isEqualTo(1.2); + } + + @Test + void aFontSizeShareScalesWithTheType() { + DocumentLetterSpacing spacing = DocumentLetterSpacing.ofFontSize(0.12); + + assertThat(spacing.type()).isEqualTo(DocumentLetterSpacing.Type.FONT_SIZE); + assertThat(spacing.resolve(24)).isEqualTo(2.88); + assertThat(spacing.resolve(10)).isEqualTo(1.2); + } + + @Test + void theTwoUnitsAreDistinguishableAtTheSameNumber() { + // The whole reason this is a value type and not a bare double: 1.2 as + // points and 1.2 as a share of the font size are wildly different, and + // a double could not say which was meant. + assertThat(DocumentLetterSpacing.points(1.2)).isNotEqualTo(DocumentLetterSpacing.ofFontSize(1.2)); + // Points pass straight through, so this one is exact. The share is a + // product of two doubles (1.2 * 24 lands on 28.799999999999997), so it + // is asserted the way a float product has to be. + assertThat(DocumentLetterSpacing.points(1.2).resolve(24)).isEqualTo(1.2); + assertThat(DocumentLetterSpacing.ofFontSize(1.2).resolve(24)).isCloseTo(28.8, within(1e-9)); + } + + @Test + void negativeTrackingIsAllowedAndTightens() { + assertThat(DocumentLetterSpacing.points(-0.5).resolve(12)).isEqualTo(-0.5); + assertThat(DocumentLetterSpacing.ofFontSize(-0.05).resolve(20)).isEqualTo(-1.0); + } + + @Test + void zeroInEitherUnitFoldsOntoTheNeutralValue() { + assertThat(DocumentLetterSpacing.points(0)).isSameAs(DocumentLetterSpacing.NONE); + assertThat(DocumentLetterSpacing.ofFontSize(0)).isSameAs(DocumentLetterSpacing.NONE); + assertThat(DocumentLetterSpacing.points(-0.0)).isSameAs(DocumentLetterSpacing.NONE); + } + + @Test + void negativeZeroFoldsOntoPositiveZeroSoOneBehaviourHasOneValue() { + DocumentLetterSpacing minusZero = new DocumentLetterSpacing(DocumentLetterSpacing.Type.FONT_SIZE, -0.0); + + assertThat(minusZero.value()).isEqualTo(0.0); + assertThat(minusZero.isNone()).isTrue(); + assertThat(minusZero).isEqualTo(new DocumentLetterSpacing(DocumentLetterSpacing.Type.FONT_SIZE, 0.0)); + } + + @Test + void aNonFiniteAmountIsRefused() { + assertThatIllegalArgumentException() + .isThrownBy(() -> DocumentLetterSpacing.points(Double.NaN)); + assertThatIllegalArgumentException() + .isThrownBy(() -> DocumentLetterSpacing.ofFontSize(Double.POSITIVE_INFINITY)); + assertThatIllegalArgumentException() + .isThrownBy(() -> DocumentLetterSpacing.points(Double.NEGATIVE_INFINITY)); + } + + @Test + void aNullUnitIsRefused() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new DocumentLetterSpacing(null, 1.0)); + } + + @Test + void aNonFiniteFontSizeMakesTheTrackingTermZeroNotNaN() { + // Bounds this term only. The rest of the measurement still multiplies + // glyph widths by the same bad font size, so this does not claim to + // make the resulting text width finite. + assertThat(DocumentLetterSpacing.ofFontSize(0.12).resolve(Double.NaN)).isZero(); + assertThat(DocumentLetterSpacing.ofFontSize(0.12).resolve(Double.POSITIVE_INFINITY)).isZero(); + } +} diff --git a/core/src/test/java/com/demcha/compose/document/style/DocumentTextStyleLetterSpacingTest.java b/core/src/test/java/com/demcha/compose/document/style/DocumentTextStyleLetterSpacingTest.java new file mode 100644 index 000000000..e2f260477 --- /dev/null +++ b/core/src/test/java/com/demcha/compose/document/style/DocumentTextStyleLetterSpacingTest.java @@ -0,0 +1,101 @@ +package com.demcha.compose.document.style; + +import com.demcha.compose.font.FontName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * How {@link DocumentTextStyle} carries tracking: the default costs nothing, + * the pre-existing constructor still means "no tracking", and the copy methods + * do not quietly drop it. + */ +class DocumentTextStyleLetterSpacingTest { + + @Test + void aStyleThatNeverAskedForTrackingHasNone() { + assertThat(DocumentTextStyle.DEFAULT.letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE); + assertThat(DocumentTextStyle.builder().build().letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE); + assertThat(DocumentTextStyle.DEFAULT.letterSpacing().resolve(DocumentTextStyle.DEFAULT.size())).isZero(); + } + + @Test + void theFourArgumentConstructorStillMeansNoTracking() { + DocumentTextStyle style = + new DocumentTextStyle(FontName.HELVETICA, 12, DocumentTextDecoration.BOLD, DocumentColor.BLACK); + + assertThat(style.letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE); + } + + @Test + void aNullTrackingNormalizesToNone() { + DocumentTextStyle viaConstructor = + new DocumentTextStyle(FontName.HELVETICA, 12, DocumentTextDecoration.DEFAULT, DocumentColor.BLACK, null); + DocumentTextStyle viaBuilder = DocumentTextStyle.builder().letterSpacing(null).build(); + + assertThat(viaConstructor.letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE); + assertThat(viaBuilder.letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE); + } + + @Test + void theBuilderCarriesTrackingThrough() { + DocumentTextStyle style = DocumentTextStyle.builder() + .size(24) + .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12)) + .build(); + + assertThat(style.letterSpacing()).isEqualTo(DocumentLetterSpacing.ofFontSize(0.12)); + assertThat(style.letterSpacing().resolve(style.size())).isEqualTo(2.88); + } + + @Test + void withSizeKeepsTheTrackingAndRescalesAFontSizeShare() { + DocumentTextStyle style = DocumentTextStyle.builder() + .size(10) + .letterSpacing(DocumentLetterSpacing.ofFontSize(0.1)) + .build(); + + DocumentTextStyle bigger = style.withSize(30); + + assertThat(bigger.letterSpacing()).isEqualTo(DocumentLetterSpacing.ofFontSize(0.1)); + // The share is kept, so the resolved amount follows the new size. + assertThat(bigger.letterSpacing().resolve(bigger.size())).isEqualTo(3.0); + } + + @Test + void withColorKeepsTheTracking() { + DocumentTextStyle style = DocumentTextStyle.builder() + .letterSpacing(DocumentLetterSpacing.points(1.5)) + .build(); + + assertThat(style.withColor(DocumentColor.rgb(255, 0, 0)).letterSpacing()) + .isEqualTo(DocumentLetterSpacing.points(1.5)); + } + + @Test + void withLetterSpacingReplacesOnlyTheTracking() { + DocumentTextStyle style = DocumentTextStyle.builder() + .fontName(FontName.TIMES_ROMAN) + .size(18) + .decoration(DocumentTextDecoration.ITALIC) + .color(DocumentColor.rgb(18, 52, 86)) + .build(); + + DocumentTextStyle tracked = style.withLetterSpacing(DocumentLetterSpacing.points(2)); + + assertThat(tracked.letterSpacing()).isEqualTo(DocumentLetterSpacing.points(2)); + assertThat(tracked.fontName()).isEqualTo(style.fontName()); + assertThat(tracked.size()).isEqualTo(style.size()); + assertThat(tracked.decoration()).isEqualTo(style.decoration()); + assertThat(tracked.color()).isEqualTo(style.color()); + } + + @Test + void trackingTakesPartInEquality() { + DocumentTextStyle plain = DocumentTextStyle.builder().size(12).build(); + DocumentTextStyle tracked = plain.withLetterSpacing(DocumentLetterSpacing.points(1)); + + assertThat(tracked).isNotEqualTo(plain); + assertThat(tracked.withLetterSpacing(DocumentLetterSpacing.NONE)).isEqualTo(plain); + } +} diff --git a/docs/recipes.md b/docs/recipes.md index 9b243c2f4..d1e47c602 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -22,6 +22,7 @@ authoring API; public application code should not import | [Tables](recipes/tables.md) | Row span, zebra rows, totals row, repeated header on page break | | [Text direction](recipes/text-direction.md) | `TextDirection` — right-to-left paragraphs, `AUTO` resolved from the text, mixed lines, and the bundled Hebrew / Arabic families | | [Rich text](recipes/rich-text.md) | `RichText` mixed-style runs in one paragraph: bold/accent/styled segments, inline links, inline images, inline SVG icons, emoji shortcodes, inline shapes and checkboxes | +| [Letter spacing](recipes/letter-spacing.md) | `DocumentLetterSpacing` — real typographic tracking for spaced caps, declared natively in PDF / PPTX / DOCX so the text layer still holds the word | | [Lists](recipes/lists.md) | `addList`: quick bulleted lists, marker customisation, nested lists with per-depth markers, spacing and styled items | | [Timelines](recipes/timelines.md) | `addTimeline`: the leading / axis / content model, markers (dot / circle / numbered / square / custom), leading column, axis sizing, `markerOnRail()`, rail extent, pagination, backends | | [Barcodes](recipes/barcodes.md) | QR / Code 128 / Code 39 / EAN / UPC / PDF417 / DataMatrix, tinting, quiet zone, card centring | diff --git a/docs/recipes/letter-spacing.md b/docs/recipes/letter-spacing.md new file mode 100644 index 000000000..462f77e0d --- /dev/null +++ b/docs/recipes/letter-spacing.md @@ -0,0 +1,113 @@ +# Letter spacing: spaced caps without wrecking the text + +Wide-set capitals are a typographic effect — a name across the top of a +CV, a section banner, an eyebrow label. The obvious way to draw them is +to put a space between every pair of letters, and it is the wrong way: +the picture is right and the file is wrong. `"JANE DOE"` written as +`"J A N E D O E"` is what search, copy/paste, a screen reader and an +applicant-tracking parser then read. + +`DocumentLetterSpacing` moves the pen instead of the text. + +```java +import com.demcha.compose.document.style.DocumentLetterSpacing; +import com.demcha.compose.document.style.DocumentTextStyle; + +DocumentTextStyle headline = DocumentTextStyle.builder() + .fontName(FontName.LATO) + .size(22) + .letterSpacing(DocumentLetterSpacing.ofFontSize(0.18)) + .build(); + +section.addParagraph(p -> p.text("JANE DOE").textStyle(headline)); +``` + +The page shows spaced caps; the file still says `JANE DOE`. + +## Two units, and why the value names its own + +```java +DocumentLetterSpacing.ofFontSize(0.18) // 18% of the font size +DocumentLetterSpacing.points(1.2) // 1.2 points, whatever the size +``` + +Prefer `ofFontSize`. Expressed as a share, the tracking scales with the +type, so one style value reads the same on a 24pt name as on an 8pt +label — and keeps its proportions under auto-size. `points` is there for +designs specified in absolute measure. + +The unit lives in the value rather than in a bare `double` because +`0.18` and `1.2` are both plausible-looking numbers and a call site +passing one has no way to say which it meant. + +Negative values tighten. `DocumentLetterSpacing.NONE` is the default and +resolves to zero at every size, so a style that never mentions tracking +renders exactly as it did. + +## What each format can express + +Tracking is declared natively, never faked, in all three outputs — PDF's +`Tc`, DrawingML's `spc`, Word's `w:spacing`. They do not all measure the +same, though: + +| | granularity | why | +|---|---|---| +| PDF and PPTX | **0.01pt** | DrawingML states spacing in hundredths of a point, so that is the finest distinction a PDF and a deck can both make. The engine measures on that grid, which keeps the width it reserves equal to the width the file draws. | +| DOCX | **0.05pt** | Word states spacing in twentieths and owns its own layout, so the export rounds to that grid independently. | + +The value you wrote is never rewritten: `resolve(fontSize)` returns what +you asked for. Ask for a third of a point and the fixed-layout formats +both use `0.33`; that is the grid, not a loss of your value. + +Tracking larger than a format can state is refused when the document is +rendered rather than silently wrapped — fixed layout tops out at +±4000pt, which is DrawingML's own bound. + +## Where the spacing lands + +One unit goes after **every** code point, the last one included, and an +ordinary space is spaced like any other character. That is what the PDF +`Tc` operator does, and PowerPoint and Word were measured doing the same. + +The practical consequence: a centred or right-aligned tracked line is +aligned on a width that includes that trailing unit, so it sits half a +unit left of where an untracked line of the same glyphs would. This is +also how CSS `letter-spacing` behaves. + +## The built-in presets + +The bundled CV and cover-letter presets use `ofFontSize(0.18)` for their +spaced-caps blocks, exposed as +`TextOrnaments.SPACED_CAPS`. Reach for the same constant if you are +writing a preset that should match them. + +### Coming from `TextOrnaments.spacedUpper` + +`spacedUpper(...)` drew spaced caps by rewriting the string with a space +between every pair of letters. It still exists and still behaves exactly +as it always did, but it is deprecated as of 2.4.0 and no built-in preset +calls it any longer. Put the text through `TextOrnaments.upper(...)` and +carry the spacing on the style instead — `SPACED_CAPS`, or any +`DocumentLetterSpacing` you prefer: + +```java +// before — the spacing is in the string +String text = TextOrnaments.spacedUpper(name); + +// after — the spacing is in the style +String text = TextOrnaments.upper(name); +DocumentTextStyle resolved = style.withLetterSpacing(TextOrnaments.SPACED_CAPS); +``` + +The reason to move is the text layer, not the look: padding the string is +what stored a name in the file as `J A N E D O E`, so that is what +search, copy/paste, screen readers and applicant-tracking parsers saw. + +The two do not render at identical widths. A whole space glyph per gap is +wider than editorial tracking, which is why `0.18` was chosen to match the +old *total* width rather than the old per-gap width. + +Runnable showcase: +[LetterSpacingExample](../../examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java) +— renders the same name at four trackings and prints what each of the +three formats says its text is. diff --git a/examples/README.md b/examples/README.md index 6652854de..c2dc391c4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -119,6 +119,7 @@ are with the canonical DSL, then jump to its detailed section below. | [World scripts](#world-scripts) | One card per bundled script — Arabic, Hebrew, Georgian, Armenian, Korean — each set in its own `FontName` family | [PDF](../assets/readme/examples/world-scripts.pdf) · [Source](src/main/java/com/demcha/examples/features/text/WorldScriptsExample.java) | | [Inline shapes](#inline-shapes) | `InlineShapeRun` — dots, arrows, chevrons, diamonds, stars, checkmarks and checkboxes drawn as geometry on the text baseline | [PDF](../assets/readme/examples/inline-shapes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineShapesExample.java) | | [Inline highlight chips](#inline-highlight-chips) | `RichText.code(text)` / `chip(text, fg, bg)` / `highlight(text, style, bg, radius, padding)` — text on a rounded padded fill (inline code + status badges), wrapping across lines | [PDF](../assets/readme/examples/inline-highlight-chips.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java) | +| [Letter spacing](#letter-spacing) | `DocumentTextStyle.builder().letterSpacing(DocumentLetterSpacing.ofFontSize(0.18))` — real typographic tracking through PDF `Tc`, DrawingML `spc` and Word `w:spacing`, so wide caps still copy and search as the word they are | [PDF](../assets/readme/examples/letter-spacing.pdf) · [Source](src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java) | | [Inline SVG icons](#inline-svg-icons) | `RichText.svgIcon(icon, size)` — a parsed multi-colour `SvgIcon` on the text baseline, crisp at any zoom and carrying its own colours | [PDF](../assets/readme/examples/inline-svg-icons.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java) | | [Colour emoji](#colour-emoji) | `RichText.emoji(":star:", size)` — GitHub-style shortcodes resolve to inline vector glyphs via the `graph-compose-emoji` artifact; unknown codes fall back to literal text | [PDF](../assets/readme/examples/emoji-shortcodes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java) | | [Section presets](#section-presets) | `pageBackground`, `band`, `softPanel`, `accentLeft / Right / Top / Bottom`, per-corner `DocumentCornerRadius` | [PDF](../assets/readme/examples/section-presets.pdf) · [Source](src/main/java/com/demcha/examples/features/text/SectionPresetsExample.java) | @@ -784,6 +785,27 @@ across lines, painting one continuous rounded fill per visual fragment. On [📄 View PDF](../assets/readme/examples/inline-highlight-chips.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java) +### Letter spacing + +`DocumentTextStyle.builder().letterSpacing(...)` (`@since 2.4.0`) takes a +`DocumentLetterSpacing` — `ofFontSize(0.18)` for a share of the type that scales +with it, or `points(1.2)` for an absolute amount; negative values tighten. The +advance is the format's own (PDF `Tc`, DrawingML `spc`, Word `w:spacing`), so the +string in the file stays the string that was typed: a headline set in wide caps +still copies, searches and parses as `JANE DOE` rather than `J A N E D O E`. +The example prints back what each of the three formats says its text is. + + +```java +.textStyle(DocumentTextStyle.builder() + .fontName(FontName.LATO).size(22) + .letterSpacing(DocumentLetterSpacing.ofFontSize(0.18)) + .build()) +``` + +[📄 View PDF](../assets/readme/examples/letter-spacing.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java) + ### Inline SVG icons `RichText.svgIcon(icon, size)` / `ParagraphBuilder.inlineSvgIcon(...)` diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index a88b6c4e5..201445dee 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -31,6 +31,7 @@ import com.demcha.examples.features.text.EmojiShortcodeExample; import com.demcha.examples.features.text.EmojiSvgVsPngExample; import com.demcha.examples.features.text.EmojiClipPathReportExample; +import com.demcha.examples.features.text.LetterSpacingExample; import com.demcha.examples.features.text.InlineShapesExample; import com.demcha.examples.features.text.ArabicArticleExample; import com.demcha.examples.features.text.HebrewInvoiceExample; @@ -197,6 +198,7 @@ public static void main(String[] args) throws Exception { // Text + sections System.out.println("Generated: " + InlineShapesExample.generate()); + System.out.println("Generated: " + LetterSpacingExample.generate()); System.out.println("Generated: " + TextDirectionExample.generate()); System.out.println("Generated: " + ArabicArticleExample.generate()); System.out.println("Generated: " + HebrewInvoiceExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java b/examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java new file mode 100644 index 000000000..14a2cb3e0 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java @@ -0,0 +1,186 @@ +package com.demcha.examples.features.text; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentLetterSpacing; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import com.demcha.examples.support.ExampleOutputPaths; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Runnable showcase for letter spacing ({@code @since 2.4.0}). + * + *

Spaced caps used to be drawn by rewriting the string with a space between + * every pair of letters. That draws the right picture and ruins the file: the + * name in a CV came back out of it as {@code "J A N E D O E"}, so search, + * copy/paste, a screen reader and an applicant-tracking parser all missed the + * one field the document is looked up by.

+ * + *

{@code DocumentLetterSpacing} moves the pen instead of the text. This + * example renders the same headline three ways, then reads its own output back + * and prints what each format says the text is — which is the whole + * point, and not something a look at the page can tell you.

+ */ +public final class LetterSpacingExample { + + private static final String NAME = "Jane O'Doe-Smith 3rd"; + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor MUTED = DocumentColor.rgb(112, 116, 128); + private static final DocumentColor BRAND = DocumentColor.rgb(20, 80, 95); + + private LetterSpacingExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare("features/text", "letter-spacing.pdf"); + byte[] pdf; + + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(40, 40, 40, 40) + .create()) { + compose(document); + pdf = document.toPdfBytes(); + } + Files.write(outputFile, pdf); + + // The picture is on the page; this is the half of the feature that is + // not. Printed rather than asserted, because an example should show the + // thing it claims. + System.out.println("PDF text layer : \"" + extracted(pdf) + "\""); + System.out.println("PPTX text : \"" + pptxText() + "\""); + System.out.println("DOCX text : \"" + docxText() + "\""); + + return outputFile; + } + + private static void compose(DocumentSession document) { + document.pageFlow() + .name("LetterSpacingShowcase") + .spacing(18) + .addSection("Intro", section -> section + .spacing(6) + .addParagraph(p -> p + .text("Letter spacing") + .textStyle(heading()) + .margin(DocumentInsets.zero())) + .addParagraph(p -> p + .text("The same name, set three ways. Select any of them and " + + "paste: the clipboard holds the name, not the spacing.") + .textStyle(body()) + .margin(DocumentInsets.zero()))) + .addSection("None", section -> specimen(section, + "no tracking", DocumentLetterSpacing.NONE)) + .addSection("Editorial", section -> specimen(section, + "ofFontSize(0.18) — what the built-in CV presets use", + DocumentLetterSpacing.ofFontSize(0.18))) + .addSection("Wide", section -> specimen(section, + "ofFontSize(0.4) — deliberately extreme", + DocumentLetterSpacing.ofFontSize(0.4))) + .addSection("Tight", section -> specimen(section, + "points(-0.4) — negative tracking tightens", + DocumentLetterSpacing.points(-0.4))) + .build(); + } + + private static void specimen(com.demcha.compose.document.dsl.SectionBuilder section, + String caption, + DocumentLetterSpacing spacing) { + section.spacing(4) + .addParagraph(p -> p + .text(caption) + .textStyle(caption()) + .margin(DocumentInsets.zero())) + .addParagraph(p -> p + .text(NAME.toUpperCase(java.util.Locale.ROOT)) + .textStyle(display().withLetterSpacing(spacing)) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero())); + } + + private static String extracted(byte[] pdf) throws Exception { + try (PDDocument document = Loader.loadPDF(pdf)) { + String text = new PDFTextStripper().getText(document); + for (String line : text.split("\\R")) { + if (line.contains("O'DOE")) { + return line.trim(); + } + } + return "(not found)"; + } + } + + private static String pptxText() throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4).margin(40, 40, 40, 40).create()) { + compose(document); + byte[] pptx = document.toPptxBytes(); + try (var show = new org.apache.poi.xslf.usermodel.XMLSlideShow( + new java.io.ByteArrayInputStream(pptx))) { + for (var shape : show.getSlides().get(0).getShapes()) { + if (shape instanceof org.apache.poi.xslf.usermodel.XSLFTextShape textShape + && textShape.getText().contains("O'DOE")) { + return textShape.getText().trim(); + } + } + } + } + return "(not found)"; + } + + private static String docxText() throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4).margin(40, 40, 40, 40).create()) { + compose(document); + byte[] docx = document.export(new DocxSemanticBackend()); + try (var word = new org.apache.poi.xwpf.usermodel.XWPFDocument( + new java.io.ByteArrayInputStream(docx))) { + for (var paragraph : word.getParagraphs()) { + if (paragraph.getText().contains("O'DOE")) { + return paragraph.getText().trim(); + } + } + } + } + return "(not found)"; + } + + private static DocumentTextStyle display() { + return DocumentTextStyle.builder() + .fontName(FontName.LATO).size(22) + .decoration(DocumentTextDecoration.BOLD).color(INK).build(); + } + + private static DocumentTextStyle heading() { + return DocumentTextStyle.builder() + .fontName(FontName.LATO).size(26) + .decoration(DocumentTextDecoration.BOLD).color(BRAND).build(); + } + + private static DocumentTextStyle body() { + return DocumentTextStyle.builder() + .fontName(FontName.LATO).size(10).color(MUTED).build(); + } + + private static DocumentTextStyle caption() { + return DocumentTextStyle.builder() + .fontName(FontName.LATO).size(8.5) + .decoration(DocumentTextDecoration.BOLD).color(MUTED).build(); + } + + public static void main(String[] args) throws Exception { + System.out.println("Wrote " + generate()); + } +} diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json index ebfdc6605..10a85a758 100644 --- a/knowledge/api/authoring.json +++ b/knowledge/api/authoring.json @@ -22,10 +22,10 @@ "graph-compose-testing:sources" ], "counts": { - "types": 235, - "methods": 2093, - "constants": 234, - "generated": 1117 + "types": 237, + "methods": 2107, + "constants": 237, + "generated": 1126 }, "packages": [ { @@ -29241,6 +29241,136 @@ } ] }, + { + "name": "DocumentLetterSpacing", + "binaryName": "com.demcha.compose.document.style.DocumentLetterSpacing", + "kind": "record", + "modifiers": [ + "final" + ], + "artifact": "graph-compose-core", + "members": [ + { + "kind": "constant", + "name": "NONE", + "static": true, + "origin": "generated", + "type": "DocumentLetterSpacing" + }, + { + "kind": "constructor", + "name": "DocumentLetterSpacing", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": null, + "params": [ + { + "type": "DocumentLetterSpacing.Type", + "name": null + }, + { + "type": "double", + "name": null + } + ] + }, + { + "kind": "method", + "name": "points", + "static": true, + "origin": "source", + "typeParameters": null, + "returns": "DocumentLetterSpacing", + "params": [ + { + "type": "double", + "name": "points" + } + ] + }, + { + "kind": "method", + "name": "ofFontSize", + "static": true, + "origin": "source", + "typeParameters": null, + "returns": "DocumentLetterSpacing", + "params": [ + { + "type": "double", + "name": "fraction" + } + ] + }, + { + "kind": "method", + "name": "resolve", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "double", + "params": [ + { + "type": "double", + "name": "fontSize" + } + ] + }, + { + "kind": "method", + "name": "isNone", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "boolean", + "params": [] + }, + { + "kind": "method", + "name": "type", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "DocumentLetterSpacing.Type", + "params": [] + }, + { + "kind": "method", + "name": "value", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "double", + "params": [] + } + ] + }, + { + "name": "DocumentLetterSpacing.Type", + "binaryName": "com.demcha.compose.document.style.DocumentLetterSpacing$Type", + "kind": "enum", + "modifiers": [ + "final" + ], + "artifact": "graph-compose-core", + "members": [ + { + "kind": "constant", + "name": "POINTS", + "static": true, + "origin": "generated", + "type": "DocumentLetterSpacing.Type" + }, + { + "kind": "constant", + "name": "FONT_SIZE", + "static": true, + "origin": "generated", + "type": "DocumentLetterSpacing.Type" + } + ] + }, { "name": "DocumentLineCap", "binaryName": "com.demcha.compose.document.style.DocumentLineCap", @@ -30269,6 +30399,36 @@ { "type": "DocumentColor", "name": null + }, + { + "type": "DocumentLetterSpacing", + "name": null + } + ] + }, + { + "kind": "constructor", + "name": "DocumentTextStyle", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": null, + "params": [ + { + "type": "FontName", + "name": "fontName" + }, + { + "type": "double", + "name": "size" + }, + { + "type": "DocumentTextDecoration", + "name": "decoration" + }, + { + "type": "DocumentColor", + "name": "color" } ] }, @@ -30309,6 +30469,20 @@ } ] }, + { + "kind": "method", + "name": "withLetterSpacing", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "DocumentTextStyle", + "params": [ + { + "type": "DocumentLetterSpacing", + "name": "letterSpacing" + } + ] + }, { "kind": "method", "name": "fontName", @@ -30344,6 +30518,15 @@ "typeParameters": null, "returns": "DocumentColor", "params": [] + }, + { + "kind": "method", + "name": "letterSpacing", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "DocumentLetterSpacing", + "params": [] } ] }, @@ -30412,6 +30595,20 @@ } ] }, + { + "kind": "method", + "name": "letterSpacing", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "DocumentTextStyle.Builder", + "params": [ + { + "type": "DocumentLetterSpacing", + "name": "letterSpacing" + } + ] + }, { "kind": "method", "name": "build", @@ -32841,6 +33038,32 @@ "artifact": "graph-compose-core", "reachedVia": "referenced by admitted API", "members": [ + { + "kind": "constructor", + "name": "TextStyle", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": null, + "params": [ + { + "type": "FontName", + "name": "fontName" + }, + { + "type": "double", + "name": "size" + }, + { + "type": "TextDecoration", + "name": "decoration" + }, + { + "type": "Color", + "name": "color" + } + ] + }, { "kind": "constructor", "name": "TextStyle", @@ -32864,6 +33087,10 @@ { "type": "Color", "name": null + }, + { + "type": "double", + "name": null } ] }, @@ -32911,6 +33138,15 @@ "typeParameters": null, "returns": "Color", "params": [] + }, + { + "kind": "method", + "name": "letterSpacing", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "double", + "params": [] } ] }, @@ -32978,6 +33214,20 @@ } ] }, + { + "kind": "method", + "name": "letterSpacing", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "TextStyle.TextStyleBuilder", + "params": [ + { + "type": "double", + "name": null + } + ] + }, { "kind": "method", "name": "build", diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md index 4039d7bdb..e531a52b1 100644 --- a/knowledge/api/authoring.md +++ b/knowledge/api/authoring.md @@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se **GraphCompose version:** 2.4.0-SNAPSHOT -Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 1117 +Types: 237 · methods: 2107 · constants: 237 · compiler-generated members: 1126 ## com.demcha.compose @@ -2185,6 +2185,19 @@ Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 111 ### DocumentLeader (enum) - constants: `NONE`, `DOTS`, `DASHES` +### DocumentLetterSpacing (record) +- `new DocumentLetterSpacing(DocumentLetterSpacing.Type, double)` +- `DocumentLetterSpacing points(double points)` +- `DocumentLetterSpacing ofFontSize(double fraction)` +- `double resolve(double fontSize)` +- `boolean isNone()` +- `DocumentLetterSpacing.Type type()` +- `double value()` +- constants: `NONE` + +### DocumentLetterSpacing.Type (enum) +- constants: `POINTS`, `FONT_SIZE` + ### DocumentLineCap (enum) - `int pdfCode()` - constants: `BUTT`, `ROUND`, `SQUARE` @@ -2273,14 +2286,17 @@ Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 111 - constants: `NONE`, `FIRST_LINE`, `FROM_SECOND_LINE`, `ALL_LINES` ### DocumentTextStyle (record) -- `new DocumentTextStyle(FontName, double, DocumentTextDecoration, DocumentColor)` +- `new DocumentTextStyle(FontName, double, DocumentTextDecoration, DocumentColor, DocumentLetterSpacing)` +- `new DocumentTextStyle(FontName fontName, double size, DocumentTextDecoration decoration, DocumentColor color)` - `DocumentTextStyle.Builder builder()` - `DocumentTextStyle withSize(double size)` - `DocumentTextStyle withColor(DocumentColor color)` +- `DocumentTextStyle withLetterSpacing(DocumentLetterSpacing letterSpacing)` - `FontName fontName()` - `double size()` - `DocumentTextDecoration decoration()` - `DocumentColor color()` +- `DocumentLetterSpacing letterSpacing()` - constants: `DEFAULT` ### DocumentTextStyle.Builder (class) @@ -2288,6 +2304,7 @@ Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 111 - `DocumentTextStyle.Builder size(double size)` - `DocumentTextStyle.Builder decoration(DocumentTextDecoration decoration)` - `DocumentTextStyle.Builder color(DocumentColor color)` +- `DocumentTextStyle.Builder letterSpacing(DocumentLetterSpacing letterSpacing)` - `DocumentTextStyle build()` ### DocumentTransform (record) @@ -2501,18 +2518,21 @@ Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 111 - constants: `DEFAULT`, `BOLD`, `ITALIC`, `BOLD_ITALIC`, `UNDERLINE`, `STRIKETHROUGH` ### TextStyle (record) -- `new TextStyle(FontName, double, TextDecoration, Color)` +- `new TextStyle(FontName fontName, double size, TextDecoration decoration, Color color)` +- `new TextStyle(FontName, double, TextDecoration, Color, double)` - `TextStyle.TextStyleBuilder builder()` - `FontName fontName()` - `double size()` - `TextDecoration decoration()` - `Color color()` +- `double letterSpacing()` ### TextStyle.TextStyleBuilder (class) - `TextStyle.TextStyleBuilder fontName(FontName)` - `TextStyle.TextStyleBuilder size(double)` - `TextStyle.TextStyleBuilder decoration(TextDecoration)` - `TextStyle.TextStyleBuilder color(Color)` +- `TextStyle.TextStyleBuilder letterSpacing(double)` - `TextStyle build()` ## com.demcha.compose.engine.components.geometry diff --git a/knowledge/api/templates.json b/knowledge/api/templates.json index 447855dbf..90f43ab37 100644 --- a/knowledge/api/templates.json +++ b/knowledge/api/templates.json @@ -23,9 +23,9 @@ ], "counts": { "types": 161, - "methods": 953, - "constants": 117, - "generated": 402 + "methods": 954, + "constants": 118, + "generated": 403 }, "packages": [ { @@ -1436,6 +1436,27 @@ ], "artifact": "graph-compose-templates", "members": [ + { + "kind": "constant", + "name": "SPACED_CAPS", + "static": true, + "origin": "generated", + "type": "DocumentLetterSpacing" + }, + { + "kind": "method", + "name": "upper", + "static": true, + "origin": "source", + "typeParameters": null, + "returns": "String", + "params": [ + { + "type": "String", + "name": "value" + } + ] + }, { "kind": "method", "name": "spacedUpper", diff --git a/knowledge/api/templates.md b/knowledge/api/templates.md index cf770d0fd..52bbde25b 100644 --- a/knowledge/api/templates.md +++ b/knowledge/api/templates.md @@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se **GraphCompose version:** 2.4.0-SNAPSHOT -Types: 161 · methods: 953 · constants: 117 · compiler-generated members: 402 +Types: 161 · methods: 954 · constants: 118 · compiler-generated members: 403 ## com.demcha.compose.document.templates.api @@ -134,8 +134,10 @@ Types: 161 · methods: 953 · constants: 117 · compiler-generated members: 402 - `void render(SectionBuilder host, String text, DocumentTextStyle style, double lineSpacing, DocumentInsets margin, TextAlign align)` ### TextOrnaments (class) +- `String upper(String value)` - `String spacedUpper(String value)` - `String joinPipe(String... parts)` +- constants: `SPACED_CAPS` ### TextStyles (class) - `DocumentTextStyle of(FontName font, double size, DocumentTextDecoration decoration, DocumentColor color)` diff --git a/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java b/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java new file mode 100644 index 000000000..19a29b390 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java @@ -0,0 +1,202 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend; +import com.demcha.compose.document.backend.fixed.pptx.PptxFixedLayoutBackend; +import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentLetterSpacing; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.apache.poi.xslf.usermodel.XSLFTextParagraph; +import org.apache.poi.xslf.usermodel.XSLFTextRun; +import org.apache.poi.xslf.usermodel.XSLFTextShape; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * One authored style, all three backends. + * + *

The point of {@code DocumentLetterSpacing} keeping its unit is that one + * declaration means the same thing everywhere. This takes a single + * {@code ofFontSize(0.12)} at 20pt — 2.4 points of tracking — and checks that + * each backend received that amount expressed in its own native unit, + * and that none of them touched the author's text to get it.

+ * + *

The three units are genuinely different numbers for the same distance: + * 2.4pt is {@code spc="240"} in DrawingML's hundredths, {@code w:val="48"} in + * Word's twentieths, and a {@code Tc} of 2.4 in the PDF's points. A test that + * asserted one number across all three would be asserting a bug.

+ */ +class LetterSpacingAcrossBackendsTest { + + private static final String NAME = "JANE DOE"; + /** 12% of 20pt. */ + private static final double EXPECTED_POINTS = 2.4; + + private static final Pattern SPC = Pattern.compile("spc=\"(-?[0-9]+)\""); + private static final Pattern W_SPACING = Pattern.compile("spacing[^/>]*val=\"(-?[0-9]+)\""); + + private static final DocumentTextStyle TRACKED = DocumentTextStyle.builder() + .fontName(FontName.LATO) + .size(20) + .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12)) + .build(); + + @Test + void theStyleResolvesToTheSameDistanceBeforeAnyBackendSeesIt() { + assertThat(TRACKED.letterSpacing().resolve(TRACKED.size())).isEqualTo(EXPECTED_POINTS); + } + + @Test + void pptxReceivesItInHundredthsOfAPoint() throws Exception { + byte[] pptx = render(session -> session.render(new PptxFixedLayoutBackend())); + + assertThat(values(pptxRunXml(pptx), SPC)) + .isNotEmpty() + .allMatch(value -> value == (int) Math.round(EXPECTED_POINTS * 100)); + assertThat(pptxText(pptx)).isEqualTo(NAME); + } + + @Test + void docxReceivesItInTwentiethsOfAPoint() throws Exception { + byte[] docx = render(session -> session.export(new DocxSemanticBackend())); + + assertThat(values(docxRunXml(docx), W_SPACING)) + .isNotEmpty() + .allMatch(value -> value == (int) Math.round(EXPECTED_POINTS * 20)); + assertThat(docxText(docx)).isEqualTo(NAME); + } + + @Test + void pdfReceivesItAsPointsOfCharacterSpacing() throws Exception { + byte[] pdf = render(session -> session.render(new PdfFixedLayoutBackend())); + + // Tc is written in points, so the operator carries the resolved value + // itself rather than a converted one. + assertThat(contentStream(pdf)).containsPattern("2\\.4\\d*\\s+Tc"); + assertThat(pdfText(pdf)).isEqualTo(NAME); + } + + @Test + void noBackendPadsTheTextToFakeIt() throws Exception { + // The whole reason the feature exists: the picture is spaced out and the + // text is not. + assertThat(pptxText(render(s -> s.render(new PptxFixedLayoutBackend())))).isEqualTo(NAME); + assertThat(docxText(render(s -> s.export(new DocxSemanticBackend())))).isEqualTo(NAME); + assertThat(pdfText(render(s -> s.render(new PdfFixedLayoutBackend())))).isEqualTo(NAME); + } + + // --- helpers --------------------------------------------------------- + + /** An export that is allowed to fail, which every backend's is. */ + @FunctionalInterface + private interface Export { + byte[] from(DocumentSession session) throws Exception; + } + + private static byte[] render(Export export) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(500, 200) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> page.addParagraph(p -> p.text(NAME).textStyle(TRACKED))); + return export.from(session); + } + } + + private static List values(List xml, Pattern pattern) { + List values = new ArrayList<>(); + for (String fragment : xml) { + Matcher matcher = pattern.matcher(fragment); + if (matcher.find()) { + values.add(Integer.valueOf(matcher.group(1))); + } + } + return values; + } + + private static List pptxRunXml(byte[] pptx) throws Exception { + List xml = new ArrayList<>(); + for (XSLFTextRun run : pptxRuns(pptx)) { + xml.add(run.getXmlObject().xmlText()); + } + return xml; + } + + private static List pptxRuns(byte[] pptx) throws Exception { + List runs = new ArrayList<>(); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + for (XSLFShape shape : show.getSlides().get(0).getShapes()) { + if (shape instanceof XSLFTextShape textShape) { + for (XSLFTextParagraph paragraph : textShape.getTextParagraphs()) { + runs.addAll(paragraph.getTextRuns()); + } + } + } + } + return runs; + } + + private static String pptxText(byte[] pptx) throws Exception { + StringBuilder text = new StringBuilder(); + for (XSLFTextRun run : pptxRuns(pptx)) { + text.append(run.getRawText()); + } + return text.toString(); + } + + private static List docxRunXml(byte[] docx) throws Exception { + List xml = new ArrayList<>(); + for (XWPFRun run : docxRuns(docx)) { + xml.add(run.getCTR().xmlText()); + } + return xml; + } + + private static List docxRuns(byte[] docx) throws Exception { + List runs = new ArrayList<>(); + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + runs.addAll(paragraph.getRuns()); + } + } + return runs; + } + + private static String docxText(byte[] docx) throws Exception { + StringBuilder text = new StringBuilder(); + for (XWPFRun run : docxRuns(docx)) { + text.append(run.text()); + } + return text.toString(); + } + + private static String pdfText(byte[] pdf) throws Exception { + try (PDDocument document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document).trim(); + } + } + + private static String contentStream(byte[] pdf) throws Exception { + try (PDDocument document = Loader.loadPDF(pdf)) { + return new String(document.getPage(0).getContents().readAllBytes(), + java.nio.charset.StandardCharsets.ISO_8859_1); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java b/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java new file mode 100644 index 000000000..04430ef32 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java @@ -0,0 +1,367 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend; +import com.demcha.compose.document.backend.fixed.pptx.PptxFixedLayoutBackend; +import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphTextSpan; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentLetterSpacing; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.apache.poi.xslf.usermodel.XSLFTextParagraph; +import org.apache.poi.xslf.usermodel.XSLFTextRun; +import org.apache.poi.xslf.usermodel.XSLFTextShape; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.within; + +/** + * One tracking value, one fixed-layout number. + * + *

A fixed-layout document reserves space, wraps, aligns and sizes its frames + * from the width the engine measured. PPTX can only declare spacing in + * hundredths of a point, so if the engine measured a finer value than that, the + * width it reserved is a width the deck will never draw. The engine therefore + * measures on the grid the file can express, and this holds the three numbers + * together: what the engine measured, what the PDF's {@code Tc} says, and what + * the deck's {@code spc} says.

+ * + *

This is not a claim that a PDF and a deck rasterise identically. + * Measured by exporting both through PowerPoint, an untracked + * forty-glyph line already lands 0.77pt apart, because PowerPoint has its own + * font handling and rounds its own output. That difference is not ours to + * remove. The one in this test is: it is arithmetic we perform, it is knowable, + * and it accumulates with the length of the string.

+ */ +class TrackingFixedLayoutParityTest { + + /** Long enough that a per-code-point residue would be unmistakable. */ + private static final String LONG = "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMN"; + private static final Pattern SPC = Pattern.compile("spc=\"(-?[0-9]+)\""); + private static final Pattern TC = Pattern.compile("(-?[0-9]*\\.?[0-9]+)\\s+Tc"); + private static final Pattern W_SPACING = Pattern.compile("spacing[^/>]*val=\"(-?[0-9]+)\""); + + private static DocumentTextStyle style(DocumentLetterSpacing spacing) { + return DocumentTextStyle.builder() + .fontName(FontName.LATO).size(20).letterSpacing(spacing).build(); + } + + // --- the invariant --------------------------------------------------- + + @ParameterizedTest(name = "[{index}] {0} pt") + @ValueSource(doubles = {1.0 / 3.0, 0.005, -1.0 / 3.0, 0.125, 2.0 / 7.0}) + void whatThePdfDeclaresIsWhatTheDeckDeclares(double points) throws Exception { + DocumentTextStyle style = style(DocumentLetterSpacing.points(points)); + + int spc = spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend()))); + double tc = tcOf(render(style, s -> s.render(new PdfFixedLayoutBackend()))); + + // Both files state the same distance. Unquantised, the PDF said + // 0.3333333333333333 where the deck said 0.33. + assertThat(spc / 100.0).as("PPTX spc=%d against PDF Tc=%s", spc, tc).isEqualTo(tc); + } + + @ParameterizedTest(name = "[{index}] {0} pt") + @ValueSource(doubles = {1.0 / 3.0, 0.005, -1.0 / 3.0, 0.125, 2.0 / 7.0}) + void whatTheEngineMeasuredIsWhatThoseFilesDeclare(double points) throws Exception { + DocumentTextStyle style = style(DocumentLetterSpacing.points(points)); + + int spc = spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend()))); + + // Read back off the LayoutGraph's measured widths, so this is the number + // wrapping and alignment actually used. Derived as a difference of two + // summed line widths, so it carries ordinary float residue — the gap + // being ruled out is four orders of magnitude larger. + assertThat(engineTracking(style)) + .as("engine measurement against declared spc=%d", spc) + .isCloseTo(spc / 100.0, within(1e-9)); + } + + @ParameterizedTest(name = "[{index}] {0} pt") + @ValueSource(doubles = {1.0 / 3.0, 0.005, -1.0 / 3.0, 2.0 / 7.0}) + void aLongStringCannotAccumulateDriftBetweenPdfAndPptx(double points) throws Exception { + DocumentTextStyle style = style(DocumentLetterSpacing.points(points)); + + double engine = engineTracking(style); + int spc = spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend()))); + int codePoints = LONG.codePointCount(0, LONG.length()); + + // Per code point they agree, so over any length they still do. + // Unquantised, a third of a point was 0.0033 out per code point and + // 0.133pt out over this line — which is what this tolerance excludes. + assertThat((spc / 100.0) * codePoints) + .as("accumulated over %d code points", codePoints) + .isCloseTo(engine * codePoints, within(1e-6)); + } + + @Test + void aTrackingBelowHalfTheGridQuantisesAwayToNone() throws Exception { + // Half-up rounding, which is Java's Math.round and what the PPTX + // conversion already used: +0.005 reaches the first hundredth while + // -0.005 does not. Stated here because it is the one asymmetry the grid + // has, and it costs a hundredth of a point at the knife edge. + assertThat(engineTracking(style(DocumentLetterSpacing.points(0.005)))) + .isCloseTo(0.01, within(1e-9)); + + DocumentTextStyle justUnder = style(DocumentLetterSpacing.points(-0.005)); + assertThat(engineTracking(justUnder)).isCloseTo(0.0, within(1e-9)); + // No tracking left to declare, so nothing is written. + assertThat(pptxRunXml(render(justUnder, s -> s.render(new PptxFixedLayoutBackend())))) + .noneMatch(xml -> SPC.matcher(xml).find()); + } + + @Test + void aValueAlreadyOnTheGridIsUntouched() throws Exception { + DocumentTextStyle style = style(DocumentLetterSpacing.points(0.25)); + + assertThat(engineTracking(style)).isCloseTo(0.25, within(1e-9)); + assertThat(spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend())))).isEqualTo(25); + } + + @Test + void zeroStaysTheExactLegacyPath() throws Exception { + DocumentTextStyle style = style(DocumentLetterSpacing.NONE); + + assertThat(engineTracking(style)).isCloseTo(0.0, within(1e-12)); + // Still no attribute and no operator at all. + assertThat(pptxRunXml(render(style, s -> s.render(new PptxFixedLayoutBackend())))) + .noneMatch(xml -> SPC.matcher(xml).find()); + assertThat(contentStream(render(style, s -> s.render(new PdfFixedLayoutBackend())))) + .doesNotContain(" Tc"); + } + + @Test + void anUntrackedDocumentIsTheSameBytesItWouldHaveBeenWithoutTheFeature() throws Exception { + // The CHANGELOG says every existing document is byte-for-byte what it + // was. That is a claim about output, so it is asserted against output: + // a style that never mentions tracking and one that explicitly asks for + // NONE must produce the identical file. + DocumentTextStyle silent = DocumentTextStyle.builder() + .fontName(FontName.LATO).size(20).build(); + DocumentTextStyle explicitNone = style(DocumentLetterSpacing.NONE); + + // PDF embeds a creation date and a document ID, so whole-file equality + // is only a meaningful question in deterministic mode. It is the right + // question, though — it covers the content stream and every dictionary. + assertThat(render(explicitNone, s -> s.render( + PdfFixedLayoutBackend.builder().deterministic(true).build()))) + .isEqualTo(render(silent, s -> s.render( + PdfFixedLayoutBackend.builder().deterministic(true).build()))); + + // The OOXML pair carry timestamps of their own, so they are compared on + // the run properties — which is where a stray zero would have shown up. + assertThat(pptxRunXml(render(explicitNone, s -> s.render(new PptxFixedLayoutBackend())))) + .isEqualTo(pptxRunXml(render(silent, s -> s.render(new PptxFixedLayoutBackend())))); + assertThat(docxRunXml(render(explicitNone, s -> s.export(new DocxSemanticBackend())))) + .isEqualTo(docxRunXml(render(silent, s -> s.export(new DocxSemanticBackend())))); + } + + @Test + void theAuthoredValueItselfIsNeverRewritten() { + // Quantisation is a property of fixed layout, not of the value. What the + // author wrote is what the style still says. + DocumentLetterSpacing authored = DocumentLetterSpacing.points(1.0 / 3.0); + DocumentTextStyle style = style(authored); + + assertThat(style.letterSpacing()).isSameAs(authored); + assertThat(style.letterSpacing().value()).isEqualTo(1.0 / 3.0); + assertThat(style.letterSpacing().resolve(20)).isEqualTo(1.0 / 3.0); + assertThat(engineTracking(style)).isCloseTo(0.33, within(1e-9)); + } + + // --- DOCX keeps its own, coarser grid -------------------------------- + + @Test + void docxRoundsToItsOwnTwentiethsFromTheAuthoredValue() throws Exception { + // Word's grid is 0.05pt, and a semantic export owes the fixed backends + // no coordinate — so it rounds the authored value itself rather than + // inheriting the hundredth the fixed path settled on. + DocumentTextStyle style = style(DocumentLetterSpacing.points(1.0 / 3.0)); + + // 1/3 pt -> 6.667 twentieths -> 7 twips = 0.35pt, which is neither the + // authored third nor the fixed path's 0.33. + assertThat(wSpacingOf(render(style, s -> s.export(new DocxSemanticBackend())))) + .isEqualTo(7); + assertThat(engineTracking(style)).isCloseTo(0.33, within(1e-9)); + } + + // --- range ------------------------------------------------------------ + + @Test + void aTrackingTooLargeForFixedLayoutIsRefusedRatherThanWrapped() { + // 2.2e7 points would be 2_200_000_000 hundredths, which lands at + // -2094967296 on the int cast: wide tracking silently becoming tight. + DocumentTextStyle style = style(DocumentLetterSpacing.points(2.2e7)); + + assertThatIllegalArgumentException() + .isThrownBy(() -> render(style, s -> s.render(new PdfFixedLayoutBackend()))) + .withMessageContaining("fixed-layout"); + } + + @Test + void theFixedLayoutBoundIsTheOneTheSchemaActuallyEnforces() throws Exception { + // ST_TextPoint validates 400000 and rejects 400001, so 4000pt is in and + // anything past it is out. + DocumentTextStyle inRange = style(DocumentLetterSpacing.points(4000.0)); + assertThat(spcOf(render(inRange, s -> s.render(new PptxFixedLayoutBackend())))) + .isEqualTo(400000); + + DocumentTextStyle outOfRange = style(DocumentLetterSpacing.points(4000.01)); + assertThatIllegalArgumentException() + .isThrownBy(() -> render(outOfRange, s -> s.render(new PptxFixedLayoutBackend()))); + } + + @Test + void aTrackingTooLargeForWordIsRefusedRatherThanWrapped() { + // 1e9 points would be 20_000_000_000 twentieths, landing at -1474836480. + DocumentTextStyle style = style(DocumentLetterSpacing.points(1e9)); + + assertThatIllegalArgumentException() + .isThrownBy(() -> render(style, s -> s.export(new DocxSemanticBackend()))) + .withMessageContaining("Word run"); + } + + @Test + void theValueTypeItselfStillAcceptsAnyFiniteNumber() { + // The limits belong to the formats, not to the authored value: it is + // only refused where it cannot be written. + assertThat(DocumentLetterSpacing.points(1e9).resolve(20)).isEqualTo(1e9); + assertThat(DocumentLetterSpacing.ofFontSize(1e9).resolve(20)).isEqualTo(2e10); + } + + // --- helpers --------------------------------------------------------- + + /** + * The tracking the engine actually measured with, per code point. + * + *

Read off the {@code LayoutGraph} — the measured span widths the whole + * fixed-layout pipeline reserves space from — rather than off any internal + * helper, so what is compared is the number that really drives wrapping, + * alignment and frame sizing. Derived as the difference the tracking made + * to the line, divided by the code points that were tracked.

+ */ + private static double engineTracking(DocumentTextStyle style) { + double tracked = measuredLineWidth(style); + double plain = measuredLineWidth(style.withLetterSpacing(DocumentLetterSpacing.NONE)); + return (tracked - plain) / LONG.codePointCount(0, LONG.length()); + } + + private static double measuredLineWidth(DocumentTextStyle style) { + try (DocumentSession session = GraphCompose.document() + .pageSize(900, 200) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> page.addParagraph(p -> p.text(LONG).textStyle(style))); + return session.layoutGraph().fragments().stream() + .map(fragment -> fragment.payload()) + .filter(payload -> payload instanceof ParagraphFragmentPayload) + .map(payload -> (ParagraphFragmentPayload) payload) + .flatMap(payload -> payload.lines().stream()) + .flatMap(line -> line.spans().stream()) + .filter(span -> span instanceof ParagraphTextSpan) + .mapToDouble(span -> ((ParagraphTextSpan) span).width()) + .sum(); + } + } + + @FunctionalInterface + private interface Export { + byte[] from(DocumentSession session) throws Exception; + } + + private static byte[] render(DocumentTextStyle style, Export export) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(900, 200) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> page.addParagraph(p -> p.text(LONG).textStyle(style))); + return export.from(session); + } + } + + private static int spcOf(byte[] pptx) throws Exception { + for (String xml : pptxRunXml(pptx)) { + Matcher matcher = SPC.matcher(xml); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + } + throw new AssertionError("no spc written"); + } + + /** Every run's properties as the document spells them, in order. */ + private static List docxRunXml(byte[] docx) throws Exception { + List xml = new ArrayList<>(); + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + for (XWPFRun run : paragraph.getRuns()) { + xml.add(run.getCTR().xmlText()); + } + } + } + return xml; + } + + private static int wSpacingOf(byte[] docx) throws Exception { + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + for (XWPFRun run : paragraph.getRuns()) { + Matcher matcher = W_SPACING.matcher(run.getCTR().xmlText()); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + } + } + } + throw new AssertionError("no w:spacing written"); + } + + private static double tcOf(byte[] pdf) throws Exception { + Matcher matcher = TC.matcher(contentStream(pdf)); + if (!matcher.find()) { + throw new AssertionError("no Tc written"); + } + return Double.parseDouble(matcher.group(1)); + } + + private static List pptxRunXml(byte[] pptx) throws Exception { + List xml = new ArrayList<>(); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + for (XSLFShape shape : show.getSlides().get(0).getShapes()) { + if (shape instanceof XSLFTextShape textShape) { + for (XSLFTextParagraph paragraph : textShape.getTextParagraphs()) { + for (XSLFTextRun run : paragraph.getTextRuns()) { + xml.add(run.getXmlObject().xmlText()); + } + } + } + } + } + return xml; + } + + private static String contentStream(byte[] pdf) throws Exception { + try (org.apache.pdfbox.pdmodel.PDDocument document = + org.apache.pdfbox.Loader.loadPDF(pdf)) { + return new String(document.getPage(0).getContents().readAllBytes(), + java.nio.charset.StandardCharsets.ISO_8859_1); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java index a842aaff4..b26f46eb7 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java @@ -65,6 +65,38 @@ void theProfileTextIsInTheFileAsItWasWritten( .contains(probe)); } + /** + * The spaced-caps blocks read back as words. + * + *

These are the blocks the probe words above deliberately avoid, and the + * reason they had to: while the look was made by rewriting the string, a + * name came out of the file as {@code "J A N E D O E"}, so the one thing + * every reader of a CV searches for — the applicant's name — was the one + * thing not in it. Tracking is a style now, so the name is a name.

+ */ + @ParameterizedTest(name = "{0}") + @MethodSource("presets") + void theNameAndSectionTitlesReadBackAsWords( + String slug, double margin, Supplier> factory) + throws Exception { + + String extracted = renderText(factory.get(), margin); + + // Two words with the single space between them intact — the old + // transform widened an authored space into three, so this is exactly + // the case it broke, and the name is the field a CV is searched by. + assertThat(extracted) + .describedAs("the applicant's name is not in the text layer of %s", slug) + .containsIgnoringCase("JANE DOE"); + // And nothing anywhere on the page is spelled out letter by letter. + // Asserted as a shape rather than against particular words, because + // presets word their headings differently and any of them regressing + // should be caught, not just the ones this test happened to name. + assertThat(extracted) + .describedAs("something is spelled out letter by letter in %s", slug) + .doesNotMatch("(?s).*\\b(?:[A-Za-z] ){3,}[A-Za-z]\\b.*"); + } + private static String renderText(DocumentTemplate template, double margin) throws Exception { byte[] pdf; diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SpacedCapsTextLayerAcrossBackendsTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SpacedCapsTextLayerAcrossBackendsTest.java new file mode 100644 index 000000000..3a87548b7 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SpacedCapsTextLayerAcrossBackendsTest.java @@ -0,0 +1,165 @@ +package com.demcha.compose.document.templates.cv.presets; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.fixed.pptx.PptxFixedLayoutBackend; +import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.SkillsSection; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.apache.poi.xslf.usermodel.XSLFTextParagraph; +import org.apache.poi.xslf.usermodel.XSLFTextRun; +import org.apache.poi.xslf.usermodel.XSLFTextShape; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A migrated preset, read back out of all three formats. + * + *

The name in a CV is the field the document is searched by, and while the + * spaced-caps look was made by rewriting the string it was the one field not in + * the file: {@code "J A N E D O E"} is what an applicant-tracking parser got. + * This holds the migration to its purpose in each format — the picture is + * spaced, the text is not.

+ * + *

The fixture carries the cases the old transform handled by three separate + * rules: several words, digits, and punctuation. It inserted one space between + * adjacent letters or digits, widened an authored space into three, + * and did neither around punctuation, so those are where it did the most + * damage.

+ */ +class SpacedCapsTextLayerAcrossBackendsTest { + + private static final String FIRST = "Jane"; + private static final String LAST = "O'Doe-Smith 3rd"; + private static final String FULL_UPPER = "JANE O'DOE-SMITH 3RD"; + private static final String TITLE_UPPER = "BACKEND ENGINEER"; + + /** Four or more single letters or digits in a row, each stranded by spaces. */ + private static final Pattern SPELLED_OUT = + Pattern.compile("\\b(?:[A-Za-z0-9] ){3,}[A-Za-z0-9]\\b"); + + private static final Supplier> PRESET = MintEditorial::create; + + @Test + void thePdfTextLayerHoldsTheNameWithItsDigitsAndPunctuation() throws Exception { + String text = pdfText(); + + assertThat(text).contains(FULL_UPPER); + assertThat(text).contains(TITLE_UPPER); + assertThat(text).doesNotContain("J A N E"); + assertThat(SPELLED_OUT.matcher(text).find()) + .describedAs("something is spelled out letter by letter in: %s", text) + .isFalse(); + } + + @Test + void thePptxRunsHoldTheNameAndDeclareTheTrackingNatively() throws Exception { + byte[] pptx = render(session -> session.render(new PptxFixedLayoutBackend())); + + StringBuilder text = new StringBuilder(); + boolean sawTracking = false; + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + for (XSLFShape shape : show.getSlides().get(0).getShapes()) { + if (!(shape instanceof XSLFTextShape textShape)) { + continue; + } + for (XSLFTextParagraph paragraph : textShape.getTextParagraphs()) { + for (XSLFTextRun run : paragraph.getTextRuns()) { + text.append(run.getRawText()).append(' '); + sawTracking |= run.getXmlObject().xmlText().contains("spc="); + } + } + } + } + + assertThat(text.toString()).contains(FULL_UPPER); + assertThat(sawTracking) + .describedAs("no run declared spc, so the deck is not actually tracked") + .isTrue(); + assertThat(SPELLED_OUT.matcher(text.toString()).find()).isFalse(); + } + + @Test + void theDocxRunsHoldTheNameAndDeclareTheTrackingNatively() throws Exception { + byte[] docx = render(session -> session.export(new DocxSemanticBackend())); + + StringBuilder text = new StringBuilder(); + boolean sawTracking = false; + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + for (XWPFRun run : paragraph.getRuns()) { + text.append(run.text()).append(' '); + sawTracking |= run.getCTR().xmlText().contains("spacing"); + } + } + } + + assertThat(text.toString()).contains(FULL_UPPER); + assertThat(sawTracking) + .describedAs("no run declared w:spacing, so the document is not tracked") + .isTrue(); + assertThat(SPELLED_OUT.matcher(text.toString()).find()).isFalse(); + } + + // --- helpers --------------------------------------------------------- + + private static String pdfText() throws Exception { + byte[] pdf = render(DocumentSession::toPdfBytes); + try (PDDocument document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document).replaceAll("\\s+", " "); + } + } + + @FunctionalInterface + private interface Export { + byte[] from(DocumentSession session) throws Exception; + } + + private static byte[] render(Export export) throws Exception { + float m = (float) MintEditorial.RECOMMENDED_MARGIN; + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(m, m, m, m) + .create()) { + PRESET.get().compose(session, document()); + return export.from(session); + } + } + + private static CvDocument document() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name(FIRST, LAST) + .jobTitle("Backend Engineer") + .contact("+44 0", "j@d.com", "London") + .build()) + .sections( + new ParagraphSection("Professional Summary", "Platform work."), + SkillsSection.builder("Technical Skills") + .group("Languages", "Java", "Kotlin") + .build(), + EntriesSection.builder("Professional Experience") + .entry("Senior Engineer", "Acme Rendering", + "2021-2024", "Built rendering services.") + .build()) + .build(); + } +} diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png index 08389bb66..4f484c6e3 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/boxed_sections-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/boxed_sections-page-0.png index 0c0844a58..a207f8a8f 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/boxed_sections-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/boxed_sections-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/centered_headline-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/centered_headline-page-0.png index 45060aad9..ba37e0137 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/centered_headline-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/centered_headline-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/classic_serif-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/classic_serif-page-0.png index 244697b94..a169b395b 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/classic_serif-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/classic_serif-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png index 58d297b65..316e33751 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/monogram_sidebar-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/monogram_sidebar-page-0.png index 0f05c9be3..d1dae8cf9 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/monogram_sidebar-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/monogram_sidebar-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/sidebar_portrait-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/sidebar_portrait-page-0.png index 538a8a6c8..051aba71e 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/sidebar_portrait-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/sidebar_portrait-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/timeline_minimal-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/timeline_minimal-page-0.png index 1bc53bcba..e9de2e079 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/timeline_minimal-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/timeline_minimal-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png index 5944e26d7..c2232517a 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png index f2e1028dc..be81d1776 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png index ed1c956e9..49e4158ad 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-0.png index dd20d39db..dea90bd58 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-1.png index d440b06e6..386c2a058 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-0.png index d65937224..24c4b9917 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-1.png index 321e39e00..4485e8fb2 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png index 0c2c81adb..adfa1806f 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png index 256431aa5..a504c34f4 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png index fa498593f..bcf5fe394 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png index 4d6112852..c4d93957a 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png index 9a9bf5a05..7d95df538 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png index 99340aeb6..805e611f2 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png index dd6224f16..7a210508d 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png differ diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 5078cd547..713ac5c93 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -1058,6 +1058,59 @@ private static void applyRunDirection(XWPFRun run, boolean rightToLeft) { } } + /** + * Writes tracking as Word's own run-level {@code w:spacing}, never as spaces + * pushed into the text. + * + *

The unit is twentieths of a point. Measured rather than assumed: + * exporting a probe document through Word itself and reading the glyph + * positions out of the PDF it wrote, {@code w:spacing w:val="100"} widened + * every step of {@code "JANE"} by 5.0pt — including the step onto a + * following untracked run, which is the trailing unit — and {@code "-30"} + * narrowed each by 1.5pt, with an ordinary space spaced like any other + * character. Word spends the value the same way the PDF {@code Tc} operator + * does.

+ * + *

This is the one place the backend resolves the public unit itself: a + * semantic export never passes through the engine's text style, which is + * where a fixed-layout backend would have had it resolved already. Word owns + * the layout here, so the contract is that the asked-for tracking arrives as + * the right native value — not that any x coordinate matches the PDF. + * Twentieths of a point quantise to 0.05pt, which is the format's own + * granularity and not something to work around.

+ * + *

No tracking writes no element, so a document that never asks for it + * carries exactly the run properties it carried before.

+ * + *

Out of range is refused rather than wrapped. The value goes out as an + * {@code int} of twentieths, and a large enough tracking changes sign on the + * cast — {@code 1e9} points becomes {@code -1474836480}, turning wide + * tracking into tight. The limit is Word's, not the fixed backends': a + * semantic document is not held to what DrawingML can spell.

+ */ + private static void applyLetterSpacing(XWPFRun run, DocumentTextStyle style) { + double points = style.letterSpacing().resolve(style.size()); + if (points == 0.0) { + return; + } + if (Math.abs(points) > MAX_TRACKING_POINTS) { + throw new IllegalArgumentException( + "Letter spacing resolves to " + points + "pt, beyond the " + + MAX_TRACKING_POINTS + "pt a Word run can express " + + "(w:spacing is twentieths of a point, written as an int)."); + } + run.setCharacterSpacing((int) Math.round(points * 20.0)); + } + + /** + * The largest tracking that survives the conversion, in points — the + * point at which twentieths stop fitting in the {@code int} the value is + * written as. Not a typographic limit: Word renders nothing remotely near + * it, and this exists only so an absurd value fails loudly instead of + * wrapping into a negative. + */ + private static final double MAX_TRACKING_POINTS = Integer.MAX_VALUE / 20.0; + private void applyStyle(XWPFRun run, DocumentTextStyle style) { if (style == null) { return; @@ -1075,6 +1128,7 @@ private void applyStyle(XWPFRun run, DocumentTextStyle style) { // and everything else at Word's own default until this is written too. run.setComplexScriptFontSize(style.size()); } + applyLetterSpacing(run, style); if (style.color() != null) { run.setColor(toHexColor(style.color().color())); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLetterSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLetterSpacingTest.java new file mode 100644 index 000000000..7fae512c9 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLetterSpacingTest.java @@ -0,0 +1,185 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentLetterSpacing; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tracking through the DOCX backend, as Word's own run-level {@code w:spacing}. + * + *

The unit was measured by exporting a probe document through real Word and + * reading the glyph positions out of the PDF it produced: {@code w:spacing} is + * twentieths of a point, spent one unit per code point with the trailing one + * included — the same rule PowerPoint and the PDF {@code Tc} operator follow.

+ * + *

What is not asserted is any x coordinate. Word owns semantic + * layout, and a DOCX export deliberately carries no measurement of its own; the + * contract is that the tracking an author asked for arrives as the right native + * value on the right run, and that the text is untouched.

+ */ +class DocxLetterSpacingTest { + + private static final String NAME = "JANE DOE"; + private static final FontName FAMILY = FontName.LATO; + /** {@code }, whatever prefix the serializer chose. */ + private static final Pattern SPACING = + Pattern.compile("spacing[^/>]*val=\"(-?[0-9]+)\""); + + private static DocumentTextStyle style(DocumentLetterSpacing spacing, double size) { + return DocumentTextStyle.builder() + .fontName(FAMILY).size(size).letterSpacing(spacing).build(); + } + + @Test + void pointsBecomeTwentiethsOfAPoint() throws Exception { + // 1.25pt -> 25 twips. + assertThat(spacingOf(render(NAME, style(DocumentLetterSpacing.points(1.25), 20)))) + .containsExactly(25); + } + + @Test + void aFontSizeShareArrivesResolvedAgainstTheStylesSize() throws Exception { + // 12% of 20pt = 2.4pt -> 48 twips. The public value keeps the unit; the + // backend resolves it here, because a semantic export never passes + // through the engine style that would have resolved it already. + assertThat(spacingOf(render(NAME, style(DocumentLetterSpacing.ofFontSize(0.12), 20)))) + .containsExactly(48); + } + + @Test + void theSameShareAtADifferentSizeResolvesDifferently() throws Exception { + // 12% of 24pt = 2.88pt -> 57.6 -> 58 twips. Twentieths quantise to + // 0.05pt; that is the format's granularity, not a rounding bug. + assertThat(spacingOf(render(NAME, style(DocumentLetterSpacing.ofFontSize(0.12), 24)))) + .containsExactly(58); + } + + @Test + void negativeTrackingIsWrittenAsANegativeValue() throws Exception { + assertThat(spacingOf(render(NAME, style(DocumentLetterSpacing.points(-0.75), 20)))) + .containsExactly(-15); + } + + @Test + void noTrackingWritesNoElementAtAll() throws Exception { + // A document that never asks for tracking carries exactly the run + // properties it carried before this existed. + byte[] docx = render(NAME, style(DocumentLetterSpacing.NONE, 20)); + + assertThat(spacingOf(docx)).isEmpty(); + assertThat(xmlOf(docx)).doesNotContain(" page.addParagraph(p -> p + .inlineText("AAAA", style(DocumentLetterSpacing.points(5), 20)) + .inlineText("BBBB", style(DocumentLetterSpacing.NONE, 20)))); + + List spacings = new ArrayList<>(); + for (XWPFRun run : runsOf(docx)) { + spacings.add(spacingOf(run)); + } + // w:spacing is a run property, so the untracked neighbour simply does + // not carry one. There is no state to leak. + assertThat(spacings).containsExactly(100, null); + assertThat(textOf(docx)).isEqualTo("AAAABBBB"); + } + + // Markdown is deliberately not covered here: MarkDownParser runs inside + // ParagraphWrapping, which is the fixed-layout path. A semantic export never + // reaches it, so the markdown-keeps-its-tracking case belongs where markdown + // is actually parsed — see PptxLetterSpacingTest. + + // --- helpers --------------------------------------------------------- + + private static byte[] render(String text, DocumentTextStyle style) throws Exception { + return renderDocument(page -> page.addParagraph(p -> p.text(text).textStyle(style))); + } + + private static byte[] renderDocument( + Consumer body) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(500, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(body); + return session.export(new DocxSemanticBackend()); + } + } + + private static List spacingOf(byte[] docx) throws Exception { + List values = new ArrayList<>(); + for (XWPFRun run : runsOf(docx)) { + Integer spacing = spacingOf(run); + if (spacing != null) { + values.add(spacing); + } + } + return values; + } + + /** + * The {@code w:spacing} value as the file spells it. + * + *

Read out of the serialized XML rather than through the schema getter, + * which returns the union type {@code Object}: the question this asks is + * what the document says, and the element is the answer.

+ */ + private static Integer spacingOf(XWPFRun run) { + Matcher matcher = SPACING.matcher(run.getCTR().xmlText()); + return matcher.find() ? Integer.valueOf(matcher.group(1)) : null; + } + + private static List runsOf(byte[] docx) throws Exception { + List runs = new ArrayList<>(); + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + runs.addAll(paragraph.getRuns()); + } + } + return runs; + } + + private static String textOf(byte[] docx) throws Exception { + StringBuilder text = new StringBuilder(); + for (XWPFRun run : runsOf(docx)) { + text.append(run.text()); + } + return text.toString(); + } + + /** The body part as the file spells it. */ + private static String xmlOf(byte[] docx) throws Exception { + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + return document.getDocument().xmlText(); + } + } +} diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java index 9e4911d3a..d11f7f1af 100644 --- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java @@ -160,6 +160,11 @@ private static boolean renderChip(PDPageContentStream stream, text = BidiVisualOrder.visualize(sanitizedLogical, span.rightToLeft()); written = PdfActualText.writtenTextOf(span); environment.markReorderedText(); + } else if (span.textStyle().letterSpacing() != 0.0) { + // As in renderLine: tracked glyphs are far enough apart that an + // extractor invents word breaks between them, so the chip states + // its own text rather than leaving a reader to guess it from gaps. + written = PdfActualText.writtenTextOf(span); } if (text.isEmpty()) { return false; // nothing to paint — no glyph-less fill or mark @@ -181,6 +186,7 @@ private static boolean renderChip(PDPageContentStream stream, textState.invalidate(); textState.applyFont(stream, font.fontType(span.textStyle().decoration()), (float) span.textStyle().size()); textState.applyColor(stream, span.textStyle().color()); + textState.applyCharacterSpacing(stream, (float) span.textStyle().letterSpacing()); if (written != null) { stream.beginMarkedContent(PdfActualText.tag(), PdfActualText.properties(written)); } @@ -429,6 +435,19 @@ private void renderLine(PDPageContentStream stream, // run stays ordinary glyphs a reader keeps whole. written = PdfActualText.writtenTextOf(textSpan); environment.markReorderedText(); + } else if (textSpan.textStyle().letterSpacing() != 0.0) { + // Tracked glyphs are the other case where the marks and + // the meaning come apart, for a different reason. The + // file is correct — one glyph per character, the right + // ToUnicode — but an extractor decides where the words + // are by how far apart the glyphs sit, and tracking is + // precisely the act of moving them apart. Left alone, + // PDFBox reads a spaced headline back as "J A N E D O E", + // which is the exact defect this feature exists to end. + // ActualText states the run's own text, and a reader that + // honours it takes that instead of guessing from gaps. + // Not markReorderedText(): nothing was reordered. + written = PdfActualText.writtenTextOf(textSpan); } if (text.isEmpty()) { cursorX += textSpan.width(); @@ -443,6 +462,7 @@ private void renderLine(PDPageContentStream stream, font.fontType(textSpan.textStyle().decoration()), (float) textSpan.textStyle().size()); textState.applyColor(stream, textSpan.textStyle().color()); + textState.applyCharacterSpacing(stream, (float) textSpan.textStyle().letterSpacing()); if (written != null) { stream.beginMarkedContent(PdfActualText.tag(), PdfActualText.properties(written)); @@ -543,6 +563,10 @@ private static final class TextRenderState { // own q..Q, so the alpha WE set is what survives — invalidate() must // not reset it. private float alpha = 1f; + // Tc. Zero is the page default, so a document with no tracking anywhere + // never emits the operator and its content stream is byte-identical to + // what it was before tracking existed. + private float characterSpacing = 0f; TextRenderState(PdfRenderEnvironment environment) { this.environment = environment; @@ -556,6 +580,24 @@ void applyFont(PDPageContentStream stream, PDFont newFont, float newSize) throws } } + /** + * Sets the tracking for the run about to be drawn. + * + *

Deduplicated like the font and the colour, and — more importantly — + * always applied before a run rather than only when the run + * wants tracking. {@code Tc} persists across {@code BT}/{@code ET} and + * over the whole {@code q..Q} block, so a span that says nothing about + * tracking keeps whatever the span before it set. Setting it back to + * zero for an untracked run is what stops a tracked headline from + * spreading the ordinary paragraph that follows it.

+ */ + void applyCharacterSpacing(PDPageContentStream stream, float newSpacing) throws IOException { + if (newSpacing != characterSpacing) { + stream.setCharacterSpacing(newSpacing); + characterSpacing = newSpacing; + } + } + void applyColor(PDPageContentStream stream, Color newColor) throws IOException { if (!newColor.equals(color)) { float newAlpha = newColor.getAlpha() / 255f; @@ -592,6 +634,12 @@ void invalidate() { font = null; size = Float.NaN; color = null; + // characterSpacing is deliberately NOT reset. Every nested draw runs + // in its own balanced q..Q, which restores Tc along with the rest of + // the graphics state, so the tracked value is still what the stream + // holds. Resetting it would force a re-emit, and in a document with + // no tracking at all that means writing a "0 Tc" that was never + // there before — a byte change for no behaviour change. } } diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java index 428ca02cb..049f705a5 100644 --- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java @@ -196,6 +196,16 @@ private void renderCellText(PDPageContentStream stream, PdfAlphaSupport.applyFillAlpha(environment, stream, cell.style().textStyle().color()); stream.setFont(font.fontType(cell.style().textStyle().decoration()), (float) cell.style().textStyle().size()); stream.setNonStrokingColor(cell.style().textStyle().color()); + double letterSpacing = cell.style().textStyle().letterSpacing(); + if (letterSpacing != 0.0) { + // One style for the whole cell, so Tc is set once here. Emitted + // only when there is tracking to apply: this q..Q block starts at + // the page default of zero, so writing "0 Tc" would add a byte to + // every table ever rendered and change nothing about any of them. + // The enclosing restoreGraphicsState puts Tc back, so a tracked + // cell cannot spread the next one. + stream.setCharacterSpacing((float) letterSpacing); + } List decorations = null; for (ResolvedTextLine line : lines) { if (line.text().isEmpty()) { @@ -210,6 +220,12 @@ private void renderCellText(PDPageContentStream stream, if (line.written() != null) { stream.beginMarkedContent(PdfActualText.tag(), PdfActualText.properties(line.written())); + } + if (line.reordered()) { + // Only a reordered line needs it: the correction it turns on + // rewrites shaped-glyph ToUnicode and costs a second pass over + // the whole document. A tracked Latin cell states its text for + // the extractor's benefit and has nothing reordered. environment.markReorderedText(); } stream.beginText(); @@ -281,9 +297,14 @@ private List resolveTextLines(PdfFont font, }; double lineBoxY = blockY + lineHeight * (safeLines.size() - lineIndex - 1); double baselineY = lineBoxY + metrics.baselineOffsetFromBottom(); + // Tracked glyphs need the same statement of intent a reordered line + // needs, for a different reason: an extractor puts word breaks where + // it sees wide gaps, and tracking is the act of widening them. The + // cell states its own text so a reader takes that instead. + boolean states = reordered || cell.style().textStyle().letterSpacing() != 0.0; resolved.add(new ResolvedTextLine(drawn, - reordered ? PdfActualText.writtenTextOf(logical) : null, - lineX, baselineY)); + states ? PdfActualText.writtenTextOf(logical) : null, + reordered, lineX, baselineY)); } return List.copyOf(resolved); @@ -329,6 +350,17 @@ private List sanitizeLines(List lines) { * @param written the same line as the author typed it, or {@code null} when the two * are the same line and nothing needs stating */ - private record ResolvedTextLine(String text, String written, double x, double baselineY) { + /** + * One drawn line of a cell. + * + *

{@code written} and {@code reordered} are not the same question, which is + * why both are carried. A line states its own text when the glyphs and the + * meaning have come apart for any reason — reordering, or tracking + * wide enough that an extractor invents word breaks. Only reordering needs + * the shaped-glyph ToUnicode correction, and that correction costs a second + * serialization pass over the whole document.

+ */ + private record ResolvedTextLine(String text, String written, boolean reordered, + double x, double baselineY) { } } diff --git a/render-pdf/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java b/render-pdf/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java index 9b50dfdc1..5294ebb48 100644 --- a/render-pdf/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java +++ b/render-pdf/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java @@ -106,6 +106,12 @@ public double getTextHeight(TextStyle style) { * drifting when input contains characters outside the font's coverage * (arrows, dots, emoji, custom unicode).

* + *

The style's tracking is included, on the same string, by the rule the + * PDF {@code Tc} operator was measured to follow — see + * {@link #trackingAdvance(TextStyle, String)}. Width and pen advance are the + * same number or every span after the first on a line is drawn somewhere + * other than where the layout thinks it is.

+ * * @param style style selecting the concrete font variant * @param text raw text from the document model * @return rendered width in points @@ -124,13 +130,54 @@ public double getTextWidth(TextStyle style, String text) { String measured = whitespaceOnly ? text : sanitizeForRender(style, text); double width = fontType(style.decoration()).getStringWidth(measured) / 1000d * size; - return width; + return width + trackingAdvance(style, measured); } catch (Exception e) { log.error("Error while getting text width {}", e.getMessage(), e); return 0; } } + /** + * The advance a style's tracking adds to {@code measured}, in points. + * + *

One unit per code point, trailing unit included — + * measured against PDFBox 3.0.8 rather than read off the specification or + * borrowed from CSS. Drawing a string at {@code Tc = 5} and reading where + * the pen actually landed: {@code "JANE"} advances 20pt further (4 code + * points), {@code "JANE DOE"} 40pt (8 — the space is a code point like any + * other), a single {@code "J"} 5pt, and the empty string not at all. The + * trailing unit is real: the pen sits one full unit past the last glyph's + * ink, which is why this counts N and not N-1. + * {@code PdfCharacterSpacingContractTest} re-measures this and fails if + * PDFBox ever changes it.

+ * + *

Counted in code points of the string that is actually drawn, + * never in {@code char}s: {@code Tc} is applied once per glyph, and a + * supplementary code point is one glyph out of two {@code char}s. (With the + * bundled faces the distinction is currently unobservable — none of them can + * encode a supplementary code point, so {@code sanitizeForRender} folds one + * to {@code '?'} before it ever reaches here. Counting code points is what + * stays correct on the day a face that can encode one is added.)

+ * + *

The result is not clamped. Negative tracking is allowed, and the pen in + * a reader genuinely moves backwards by it; clamping the measurement while + * being unable to clamp the reader is how the two stop agreeing.

+ * + * @param style the style whose tracking to apply, already resolved to points + * @param measured the exact string handed to {@code showText} + * @return the extra advance in points, {@code 0} when there is no tracking + */ + private static double trackingAdvance(TextStyle style, String measured) { + double spacing = style.letterSpacing(); + if (spacing == 0.0 || measured == null || measured.isEmpty()) { + // Short-circuited rather than added as a zero, so an untracked + // style returns the identical double it returned before tracking + // existed. + return 0.0; + } + return measured.codePointCount(0, measured.length()) * spacing; + } + /** * Sanitises {@code text} for safe rendering with the font selected by * {@code style}. Applies the standard control-character cleanup that @@ -224,12 +271,26 @@ public String sanitizeForLogicalTextExport(TextStyle style, String text) { fontType(style.decoration()), textSanitizer(text)); } + /** + * Measures {@code text} exactly as given, for a caller that has already + * sanitised it. + * + *

Tracking is applied here too, by the same rule and on the same string, + * so the two entry points cannot disagree about the width of one run. There + * is no double application: this does not delegate to + * {@link #getTextWidth(TextStyle, String)}, and that one does not delegate + * here — each adds the tracking once, to the string it measured.

+ * + * @param style style selecting the concrete font variant + * @param text already-sanitised text + * @return rendered width in points + */ public double getTextWidthNoSanitize(TextStyle style, String text) { double size = style.size(); try { float width = fontType(style.decoration()).getStringWidth(text) / 1000 * (float) size; log.debug("Getting text width: " + width); - return width; + return width + trackingAdvance(style, text); } catch (Exception e) { e.printStackTrace(); log.error("Error while getting text width {}", e.getMessage(), e); @@ -245,8 +306,13 @@ private String textSanitizer(String text) { // measures against the input as written. The collapse therefore // shrank the rendered string under measurement, drifting // link annotations away from their glyphs and visually merging - // author-spaced strings like `spacedUpper("ARTEM DEMCHYSHYN")` - // (which inserts deliberate triple-spaces between words). + // any text whose author put more than one space in it on purpose. + // (The case that first showed this was the templates' old + // spaced-caps transform, which padded a name out to + // "A R T E M D E M C H Y S H Y N". That is gone — tracking is a + // style now — but the rule it exposed is not about that transform: + // a run of author spaces is content, and measurement and drawing + // have to agree on how wide it is.) // Newlines / NBSP / non-tab control chars still resolve to a // single space each \u2014 they no longer collapse adjacent author // spaces. diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/DrawnPen.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/DrawnPen.java new file mode 100644 index 000000000..0abc05f8c --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/DrawnPen.java @@ -0,0 +1,113 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.pdmodel.PDDocument; + +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.util.Matrix; +import org.apache.pdfbox.util.Vector; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Where the pen actually put each glyph. + * + *

{@code PDFTextStripper} answers a different question — what a reader + * makes of the page — and two things this codebase does deliberately get in the + * way of using it for geometry. It invents a word break wherever glyphs sit far + * apart, which is what tracking is; and where a run states its own + * {@code ActualText} it reports that string instead of the glyphs, so the + * positions stop lining up one to one.

+ * + *

This reads the drawing operators instead. {@code showGlyph} is the callback + * PDFBox makes for each glyph it paints, with the text-rendering matrix at that + * moment, so the x it reports is the pen — {@code Tc} and all — and no + * extraction heuristic or marked-content section stands in between.

+ */ +final class DrawnPen { + + private DrawnPen() { + } + + /** One painted glyph: the pen position it was placed at, and how far it advanced. */ + record Placement(double x, double y, double advance) { + + @Override + public String toString() { + return String.format("@%.2f(+%.2f)", x, advance); + } + } + + /** + * Every glyph the first page paints, in painting order. + * + * @param pdf a rendered document + * @return the pen positions + * @throws IOException if the document cannot be read + */ + static List placements(byte[] pdf) throws IOException { + return placements(pdf, 0); + } + + /** + * Every glyph a page paints, in painting order. + * + * @param pdf a rendered document + * @param index zero-based page index + * @return the pen positions + * @throws IOException if the document cannot be read + */ + static List placements(byte[] pdf, int index) throws IOException { + List placements = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(pdf)) { + // Driven through PDFTextStripper because a bare PDFStreamEngine has + // no operators registered and would process nothing. Only showGlyph + // is taken from it — that callback sits below the ActualText + // substitution the stripper's own text output goes through. + PDFTextStripper engine = new PDFTextStripper() { + @Override + protected void showGlyph(Matrix textRenderingMatrix, PDFont font, int code, + Vector displacement) throws IOException { + placements.add(new Placement( + textRenderingMatrix.getTranslateX(), + textRenderingMatrix.getTranslateY(), + displacement.getX() * textRenderingMatrix.getScalingFactorX())); + super.showGlyph(textRenderingMatrix, font, code, displacement); + } + }; + engine.setStartPage(index + 1); + engine.setEndPage(index + 1); + engine.getText(document); + } + return placements; + } + + /** The x of the first glyph painted on the page. */ + static double firstX(byte[] pdf) throws IOException { + return placements(pdf).get(0).x(); + } + + /** The x of the last glyph painted on the page. */ + static double lastX(byte[] pdf) throws IOException { + List placements = placements(pdf); + return placements.get(placements.size() - 1).x(); + } + + /** + * Pen distance from the first glyph to the last. + * + *

Note this is N-1 gaps, not the run's full advance: the pen's final + * trailing step lands past the last glyph and no glyph records it.

+ */ + static double firstToLastX(byte[] pdf) throws IOException { + return lastX(pdf) - firstX(pdf); + } + + /** Distance from glyph {@code i-1}'s pen position to glyph {@code i}'s. */ + static double step(List placements, int i) { + return placements.get(i).x() - placements.get(i - 1).x(); + } +} diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfCharacterSpacingContractTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfCharacterSpacingContractTest.java new file mode 100644 index 000000000..062aae2bf --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfCharacterSpacingContractTest.java @@ -0,0 +1,161 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * What PDFBox's {@code Tc} operator actually does to the pen — measured, and + * pinned. + * + *

The whole feature rests on one number: how many spacing units a string of + * N code points adds to the pen advance. The specification can be read either + * way by a careful person and CSS {@code letter-spacing} is a different + * product, so this measures it rather than citing it. {@code PdfFont} adds the + * same number to its measurement; if PDFBox ever changes the rule, this test + * says so instead of leaving every tracked line silently misplaced.

+ * + *

The measurement trick is a marker glyph drawn immediately after the + * subject inside the same {@code BT}/{@code ET} with no repositioning: the + * marker's left edge is the pen position the subject left behind, + * trailing spacing included. Reading the subject's own ink extent instead would + * report N-1 by construction and prove nothing.

+ */ +class PdfCharacterSpacingContractTest { + + private static final String FONT = "fonts/google/lato/Lato-Regular.ttf"; + private static final float SIZE = 12f; + private static final float START_X = 50f; + /** Large enough that N and N-1 cannot be confused with rounding. */ + private static final float SPACING = 5f; + private static final String MARKER = "|"; + + /** + * No bundled family encodes a supplementary code point, so one can never + * reach the backend — {@code sanitizeForRender} folds it to {@code '?'} + * first. That case is covered through the production path instead; putting + * it here would only prove that PDFBox throws on a glyph it does not have. + */ + @ParameterizedTest(name = "[{index}] \"{0}\" -> {1} unit(s)") + @CsvSource({ + "JANE, 4", // plain ASCII + "'JANE DOE',8", // a normal space is a code point like any other + "J, 1", // one code point + "JOSÉ, 4", // a covered non-ASCII code point is still one unit + }) + void trackingAddsOneUnitPerCodePointIncludingTheTrailingOne(String subject, int expectedUnits) + throws Exception { + double untracked = advanceOf(subject, 0f); + double tracked = advanceOf(subject, SPACING); + + assertThat(tracked - untracked) + .as("%s: %d code points at %s pt of tracking", subject, + subject.codePointCount(0, subject.length()), SPACING) + .isCloseTo(expectedUnits * SPACING, within(0.01)); + } + + @Test + void theRuleIsNotOffByOneAgainstTheCodePointCount() throws Exception { + // Stated separately so a change from N to N-1 reads as what it is + // rather than as four arithmetic failures. + String subject = "JANE"; + int codePoints = subject.codePointCount(0, subject.length()); + + assertThat(codePoints).isEqualTo(4); + assertThat(advanceOf(subject, SPACING) - advanceOf(subject, 0f)) + .as("N (trailing unit included), not N-1") + .isCloseTo(codePoints * SPACING, within(0.01)) + .isNotCloseTo((codePoints - 1) * SPACING, within(0.01)); + } + + @Test + void theEmptyStringTakesNoTrackingAtAll() throws Exception { + assertThat(advanceOf("", SPACING)).isCloseTo(advanceOf("", 0f), within(0.001)); + assertThat(advanceOf("", SPACING)).isCloseTo(0.0, within(0.001)); + } + + @Test + void trackingDoesNotTouchTheTextLayer() throws Exception { + assertThat(extractionOf("JANE DOE", SPACING)).isEqualTo("JANE DOE|"); + assertThat(extractionOf("JANE DOE", 0f)).isEqualTo("JANE DOE|"); + } + + private static double advanceOf(String subject, float spacing) throws IOException { + List positions = positions(render(subject, spacing)); + TextPosition marker = positions.get(positions.size() - 1); + return marker.getXDirAdj() - START_X; + } + + private static String extractionOf(String subject, float spacing) throws IOException { + StringBuilder extracted = new StringBuilder(); + for (TextPosition position : positions(render(subject, spacing))) { + extracted.append(position.getUnicode()); + } + return extracted.toString(); + } + + /** Draws {@code subject} at {@code spacing}, then a marker with no repositioning. */ + private static byte[] render(String subject, float spacing) throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = loadFont(document); + + try (PDPageContentStream stream = new PDPageContentStream(document, page)) { + stream.beginText(); + stream.setFont(font, SIZE); + stream.newLineAtOffset(START_X, 700); + stream.setCharacterSpacing(spacing); + if (!subject.isEmpty()) { + stream.showText(subject); + } + stream.showText(MARKER); + stream.endText(); + } + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + document.save(bytes); + return bytes.toByteArray(); + } + } + + private static PDFont loadFont(PDDocument document) throws IOException { + try (InputStream ttf = PdfCharacterSpacingContractTest.class + .getClassLoader().getResourceAsStream(FONT)) { + if (ttf == null) { + throw new IllegalStateException("font resource missing: " + FONT); + } + return PDType0Font.load(document, ttf, true); + } + } + + private static List positions(byte[] pdf) throws IOException { + List all = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(pdf)) { + new PDFTextStripper() { + @Override + protected void writeString(String text, List positions) { + all.addAll(positions); + } + }.getText(document); + } + return all; + } +} diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLetterSpacingMeasurementTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLetterSpacingMeasurementTest.java new file mode 100644 index 000000000..150c5b871 --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLetterSpacingMeasurementTest.java @@ -0,0 +1,148 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import com.demcha.compose.engine.components.content.text.TextDecoration; +import com.demcha.compose.engine.components.content.text.TextStyle; +import com.demcha.compose.engine.render.pdf.PdfFont; +import com.demcha.compose.font.FontLibrary; +import com.demcha.compose.font.FontName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.awt.Color; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * What tracking does to a measured width. + * + *

Held to the rule {@link PdfCharacterSpacingContractTest} measured off + * PDFBox — one unit per code point, trailing unit included — because the + * measurement and the pen have to produce the same number. A test that invented + * its own expectation here would pass while the page drifted.

+ */ +class PdfFontLetterSpacingMeasurementTest { + + private static final FontName FAMILY = FontName.LATO; + private static final double SIZE = 12.0; + + private static PDDocument document; + private static PdfFont font; + + @BeforeAll + static void loadFont() { + document = new PDDocument(); + FontLibrary library = PdfFontLibraryFactory.library(document, List.of()); + font = library.getFont(FAMILY, PdfFont.class).orElseThrow(); + } + + @AfterAll + static void closeDocument() throws Exception { + document.close(); + } + + private static TextStyle style(double letterSpacing) { + return new TextStyle(FAMILY, SIZE, TextDecoration.DEFAULT, Color.BLACK, letterSpacing); + } + + private static double units(String text, double spacing) { + return font.getTextWidth(style(spacing), text) - font.getTextWidth(style(0), text); + } + + @Test + void withoutTrackingTheWidthIsTheIdenticalDoubleItAlwaysWas() { + // Not "close to" — the same bits. The zero path short-circuits rather + // than adding a zero, so every existing document measures as before. + String text = "The quick brown fox jumps over the lazy dog"; + + TextStyle legacy = new TextStyle(FAMILY, SIZE, TextDecoration.DEFAULT, Color.BLACK); + TextStyle explicitZero = style(0.0); + + assertThat(font.getTextWidth(explicitZero, text)) + .isEqualTo(font.getTextWidth(legacy, text)); + } + + @Test + void positiveTrackingAddsOneUnitPerCodePoint() { + assertThat(units("JANE", 2.0)).isCloseTo(4 * 2.0, within(1e-9)); + assertThat(units("J", 2.0)).isCloseTo(2.0, within(1e-9)); + } + + @Test + void aNormalSpaceCountsAsACodePointLikeAnyOther() { + // "JANE DOE" is eight code points, not seven: the space is tracked too, + // which is exactly what Tc does to the pen. + assertThat(units("JANE DOE", 2.0)).isCloseTo(8 * 2.0, within(1e-9)); + } + + @Test + void negativeTrackingTightensByTheSameRule() { + assertThat(units("JANE", -0.5)).isCloseTo(4 * -0.5, within(1e-9)); + assertThat(font.getTextWidth(style(-0.5), "JANE")) + .isLessThan(font.getTextWidth(style(0), "JANE")); + } + + @Test + void anEmptyStringIsZeroWideWhateverTheTracking() { + assertThat(font.getTextWidth(style(5.0), "")).isZero(); + assertThat(font.getTextWidth(style(0), "")).isZero(); + } + + @Test + void aSupplementaryCodePointCostsOneUnitNotTwo() { + // No bundled face can encode one, so sanitizeForRender folds it to '?' + // before measurement — one char, one code point, one unit. Counting + // Java chars on the raw string would bill it twice. + String astral = new String(Character.toChars(0x1D400)); + assertThat(astral.length()).isEqualTo(2); + assertThat(astral.codePointCount(0, astral.length())).isEqualTo(1); + + assertThat(units(astral, 3.0)).isCloseTo(3.0, within(1e-9)); + assertThat(units("A" + astral + "B", 3.0)).isCloseTo(3 * 3.0, within(1e-9)); + } + + @Test + void trackingIsBilledOnTheSanitizedStringNotTheAuthorsOne() { + // A control character collapses to a single space before drawing, so it + // is billed once — as the thing that is actually drawn, not as what was + // typed. Measuring the raw string would bill a glyph that never appears. + String withControl = "AB\u0000CD"; + String sanitized = font.sanitizeForRender(style(0), withControl); + + assertThat(sanitized).hasSize(5); + assertThat(units(withControl, 2.0)) + .isCloseTo(sanitized.codePointCount(0, sanitized.length()) * 2.0, within(1e-9)); + } + + @Test + void theTwoEntryPointsAgreeAndNeitherBillsTrackingTwice() { + String alreadySanitized = "JANE"; + + double trackedSanitizing = font.getTextWidth(style(2.0), alreadySanitized); + double trackedNoSanitize = font.getTextWidthNoSanitize(style(2.0), alreadySanitized); + + // The tracking each one adds is the same number, exactly: one rule, one + // string, applied once by each entry point and never twice. Four units, + // not eight. + assertThat(trackedNoSanitize - font.getTextWidthNoSanitize(style(0), alreadySanitized)) + .isEqualTo(4 * 2.0); + assertThat(trackedSanitizing - font.getTextWidth(style(0), alreadySanitized)) + .isEqualTo(4 * 2.0); + + // The absolute widths agree only to float precision, and did so before + // tracking existed: getTextWidthNoSanitize computes in float while + // getTextWidth computes in double. That is pre-existing and untouched + // here — it is asserted so the gap is documented rather than discovered. + assertThat(trackedNoSanitize).isCloseTo(trackedSanitizing, within(1e-4)); + } + + @Test + void trackingScalesTheWholeRunNotJustItsEnds() { + // Guards the N vs N-1 boundary at the measurement layer: a one-code-point + // string gets a full unit, which N-1 would make zero. + assertThat(units("J", 4.0)).isCloseTo(4.0, within(1e-9)).isNotCloseTo(0.0, within(1e-6)); + } +} diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacingRenderTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacingRenderTest.java new file mode 100644 index 000000000..40892b039 --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacingRenderTest.java @@ -0,0 +1,328 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentLetterSpacing; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSNumber; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Tracking on a real page: what it moves, and what it must not touch. + * + *

The point of the feature is that the picture changes and the text does + * not. A headline set in spaced caps has to still be "JANE DOE" to + * anything that reads the file — search, copy/paste, a screen reader, an + * applicant-tracking parser — which is exactly what padding the string with + * spaces destroys.

+ * + *

The geometry cases are not four more implementations being checked. + * Alignment, wrapping, decoration rules and link rectangles all consume one + * measured span width, so they are evidence that the width carries tracking, + * not evidence that four code paths each learned about it.

+ * + *

Geometry is read through {@link DrawnPen} rather than the text stripper: + * tracked runs state their own {@code ActualText}, and a stripper honouring + * that reports the stated string instead of the glyphs it covers.

+ */ +class PdfLetterSpacingRenderTest { + + private static final String NAME = "JANE DOE"; + private static final FontName FAMILY = FontName.LATO; + + private static DocumentTextStyle style(DocumentLetterSpacing spacing) { + return DocumentTextStyle.builder() + .fontName(FAMILY).size(20).letterSpacing(spacing).build(); + } + + private static DocumentTextStyle underlined(DocumentLetterSpacing spacing) { + return DocumentTextStyle.builder() + .fontName(FAMILY).size(20) + .decoration(DocumentTextDecoration.UNDERLINE) + .letterSpacing(spacing).build(); + } + + // --- the text layer ------------------------------------------------- + + @Test + void aTrackedHeadlineStillReadsAsTheWordThatWasWritten() throws Exception { + byte[] pdf = render(page -> page.addParagraph(p -> p + .text(NAME).textStyle(style(DocumentLetterSpacing.ofFontSize(0.3))))); + + // Not "J A N E D O E". The string was never touched; only the pen was. + assertThat(extractedText(pdf)).isEqualTo(NAME); + } + + @Test + void theTextLayerSurvivesTrackingWideEnoughToFoolAnExtractor() throws Exception { + // Without the run stating its own text this comes back "J A N E D O E": + // an extractor decides where words are by how far apart glyphs sit, and + // tracking is the act of moving them apart. The file was always right — + // eight glyphs, correct ToUnicode — and a reader still got it wrong. + for (double points : new double[] {2, 4, 8, 16}) { + byte[] pdf = render(page -> page.addParagraph(p -> p + .text(NAME).textStyle(style(DocumentLetterSpacing.points(points))))); + + assertThat(extractedText(pdf)) + .as("extraction at %s pt of tracking", points) + .isEqualTo(NAME); + } + } + + @Test + void aTrackedRunDrawsTheGlyphsItWasGivenAndNoExtraSpaces() throws Exception { + byte[] tracked = render(page -> page.addParagraph(p -> p + .text(NAME).textStyle(style(DocumentLetterSpacing.points(4))))); + + // One glyph per character of the original string — the spaced-out + // imitation this replaces would have painted seven extra space glyphs. + assertThat(DrawnPen.placements(tracked)).hasSize(NAME.length()); + } + + @Test + void trackingActuallySpreadsTheGlyphs() throws Exception { + byte[] plain = render(page -> page.addParagraph(p -> p.text(NAME) + .textStyle(style(DocumentLetterSpacing.NONE)))); + byte[] tracked = render(page -> page.addParagraph(p -> p.text(NAME) + .textStyle(style(DocumentLetterSpacing.points(4))))); + + // Seven steps between eight glyphs, each four points longer. The eighth + // unit is past the last glyph, so no glyph records it — which is exactly + // why measurement counts N and this counts N-1. + assertThat(DrawnPen.firstToLastX(tracked) - DrawnPen.firstToLastX(plain)) + .isCloseTo(7 * 4.0, within(0.01)); + } + + @Test + void everyStepIsWidenedByExactlyTheTracking() throws Exception { + List plain = DrawnPen.placements( + render(page -> page.addParagraph(p -> p.text(NAME) + .textStyle(style(DocumentLetterSpacing.NONE))))); + List tracked = DrawnPen.placements( + render(page -> page.addParagraph(p -> p.text(NAME) + .textStyle(style(DocumentLetterSpacing.points(4)))))); + + assertThat(tracked).hasSameSizeAs(plain); + for (int i = 1; i < tracked.size(); i++) { + assertThat(DrawnPen.step(tracked, i) - DrawnPen.step(plain, i)) + .as("step %d", i) + .isCloseTo(4.0, within(0.01)); + } + } + + // --- text-state management ------------------------------------------ + + @Test + void trackingDoesNotLeakIntoTheParagraphAfterIt() throws Exception { + byte[] mixed = render(page -> { + page.addParagraph(p -> p.text(NAME).textStyle(style(DocumentLetterSpacing.points(6)))); + page.addParagraph(p -> p.text(NAME).textStyle(style(DocumentLetterSpacing.NONE))); + }); + byte[] reference = render(page -> { + page.addParagraph(p -> p.text(NAME).textStyle(style(DocumentLetterSpacing.NONE))); + page.addParagraph(p -> p.text(NAME).textStyle(style(DocumentLetterSpacing.NONE))); + }); + + List mixedGlyphs = DrawnPen.placements(mixed); + List plainGlyphs = DrawnPen.placements(reference); + assertThat(mixedGlyphs).hasSize(16); + assertThat(plainGlyphs).hasSize(16); + + // First paragraph's steps are six points longer... + assertThat(DrawnPen.step(mixedGlyphs, 1) - DrawnPen.step(plainGlyphs, 1)) + .isCloseTo(6.0, within(0.01)); + // ...and the untracked paragraph after it is drawn exactly as if the + // tracked one were not there. Tc is restored with the graphics state. + for (int i = 9; i < 16; i++) { + assertThat(DrawnPen.step(mixedGlyphs, i)) + .as("second paragraph, step %d", i) + .isCloseTo(DrawnPen.step(plainGlyphs, i), within(0.01)); + } + } + + @Test + void trackedAndUntrackedRunsOnOneLineEachKeepTheirOwn() throws Exception { + // Two runs inside one paragraph are drawn inside a single BT/ET on the + // implicit pen, so this is where a leaked Tc would show: the second run + // would spread and the line would end somewhere else entirely. + List mixed = DrawnPen.placements( + render(page -> page.addParagraph(p -> p + .inlineText("AAAA", style(DocumentLetterSpacing.points(5))) + .inlineText("BBBB", style(DocumentLetterSpacing.NONE))))); + List neither = DrawnPen.placements( + render(page -> page.addParagraph(p -> p + .inlineText("AAAA", style(DocumentLetterSpacing.NONE)) + .inlineText("BBBB", style(DocumentLetterSpacing.NONE))))); + + assertThat(mixed).hasSize(8); + assertThat(neither).hasSize(8); + + // Steps inside "AAAA" are five points longer... + for (int i = 1; i < 4; i++) { + assertThat(DrawnPen.step(mixed, i) - DrawnPen.step(neither, i)) + .as("tracked run, step %d", i).isCloseTo(5.0, within(0.01)); + } + // ...while steps inside "BBBB" are the untracked ones, however far the + // A's pushed them to the right. + for (int i = 5; i < 8; i++) { + assertThat(DrawnPen.step(mixed, i)) + .as("untracked run, step %d", i) + .isCloseTo(DrawnPen.step(neither, i), within(0.01)); + } + } + + // --- consumers of the measured width -------------------------------- + + @Test + void rightAlignmentUsesTheTrackedWidth() throws Exception { + double plainLast = DrawnPen.lastX(render(page -> page.addParagraph(p -> p + .text(NAME).align(TextAlign.RIGHT).textStyle(style(DocumentLetterSpacing.NONE))))); + double trackedLast = DrawnPen.lastX(render(page -> page.addParagraph(p -> p + .text(NAME).align(TextAlign.RIGHT).textStyle(style(DocumentLetterSpacing.points(3)))))); + + // A right-aligned line ends at the margin whatever its width, which only + // holds if the aligner used the tracked width. The trailing unit is part + // of that width, so the last glyph is placed one unit short of where the + // untracked line's last glyph sat. + assertThat(trackedLast).isCloseTo(plainLast - 3.0, within(0.05)); + } + + @Test + void centreAlignmentUsesTheTrackedWidth() throws Exception { + double plainFirst = DrawnPen.firstX(render(page -> page.addParagraph(p -> p + .text(NAME).align(TextAlign.CENTER).textStyle(style(DocumentLetterSpacing.NONE))))); + double trackedFirst = DrawnPen.firstX(render(page -> page.addParagraph(p -> p + .text(NAME).align(TextAlign.CENTER).textStyle(style(DocumentLetterSpacing.points(3)))))); + + // Centring a wider line starts it further left, by half the extra width + // — half of all eight units, trailing one included. + assertThat(plainFirst - trackedFirst).isCloseTo(8 * 3.0 / 2.0, within(0.05)); + } + + @Test + void trackingMovesTheWrappingBoundary() throws Exception { + // A phrase that fits on one line untracked and cannot once tracked. If + // wrapping measured the untracked width, both would be one line. + String phrase = "SENIOR ENGINEER"; + + assertThat(lineCount(renderNarrow(phrase, DocumentLetterSpacing.NONE))).isEqualTo(1); + assertThat(lineCount(renderNarrow(phrase, DocumentLetterSpacing.points(6)))) + .isGreaterThan(1); + } + + @Test + void anUnderlineCoversTheWholeTrackedRun() throws Exception { + double plainRule = widestFilledRectangle(render(page -> page.addParagraph(p -> p + .text(NAME).textStyle(underlined(DocumentLetterSpacing.NONE))))); + double trackedRule = widestFilledRectangle(render(page -> page.addParagraph(p -> p + .text(NAME).textStyle(underlined(DocumentLetterSpacing.points(4)))))); + + // The decoration segment is built from the span's measured width, so a + // wider run draws a wider rule rather than one that stops short of it. + assertThat(trackedRule - plainRule).isCloseTo(8 * 4.0, within(0.5)); + } + + @Test + void aLinkRectangleCoversTheWholeTrackedRun() throws Exception { + double plainWidth = linkWidth(render(page -> page.addParagraph(p -> p + .text(NAME).link(new DocumentLinkOptions("https://example.com")) + .textStyle(style(DocumentLetterSpacing.NONE))))); + double trackedWidth = linkWidth(render(page -> page.addParagraph(p -> p + .text(NAME).link(new DocumentLinkOptions("https://example.com")) + .textStyle(style(DocumentLetterSpacing.points(4)))))); + + assertThat(trackedWidth - plainWidth).isCloseTo(8 * 4.0, within(0.5)); + } + + // --- helpers --------------------------------------------------------- + + private static int lineCount(byte[] pdf) throws IOException { + return DrawnGlyphs.byLine(pdf).size(); + } + + private static String extractedText(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document).trim(); + } + } + + private static double linkWidth(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + List annotations = document.getPage(0).getAnnotations(); + assertThat(annotations).isNotEmpty(); + return annotations.get(0).getRectangle().getWidth(); + } + } + + /** + * The widest rectangle the page fills — the underline rule, read off the + * content stream rather than guessed from the text. + */ + private static double widestFilledRectangle(byte[] pdf) throws IOException { + double widest = 0.0; + try (PDDocument document = Loader.loadPDF(pdf)) { + PDFStreamParser parser = new PDFStreamParser(document.getPage(0)); + List operands = new ArrayList<>(); + Object token; + double pendingWidth = 0.0; + while ((token = parser.parseNextToken()) != null) { + if (token instanceof COSBase operand) { + operands.add(operand); + continue; + } + if (token instanceof Operator operator) { + if ("re".equals(operator.getName()) && operands.size() >= 4) { + pendingWidth = ((COSNumber) operands.get(operands.size() - 2)).floatValue(); + } else if ("f".equals(operator.getName()) || "f*".equals(operator.getName())) { + widest = Math.max(widest, pendingWidth); + } + operands.clear(); + } + } + } + return widest; + } + + private static byte[] render(Consumer body) { + try (DocumentSession document = GraphCompose.document() + .pageSize(400, 200) + .margin(DocumentInsets.of(20)) + .create()) { + document.pageFlow(body); + return document.toPdfBytes(); + } + } + + private static byte[] renderNarrow(String text, DocumentLetterSpacing spacing) { + try (DocumentSession document = GraphCompose.document() + .pageSize(180, 200) + .margin(DocumentInsets.of(10)) + .create()) { + document.pageFlow(page -> page.addParagraph(p -> p.text(text).textStyle( + DocumentTextStyle.builder().fontName(FAMILY).size(14) + .letterSpacing(spacing).build()))); + return document.toPdfBytes(); + } + } +} diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java index 9640d69ef..60508a668 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java @@ -18,6 +18,7 @@ import org.openxmlformats.schemas.presentationml.x2006.main.CTShape; import java.awt.Color; +import java.util.Optional; import java.awt.geom.Rectangle2D; import java.util.List; @@ -157,6 +158,7 @@ static void applyStyle(XSLFTextRun run, run.setUnderlined(PptxFontMapping.isUnderline(style)); run.setStrikethrough(PptxFontMapping.isStrikethrough(style)); disableKerning(run); + applyLetterSpacing(run, style.letterSpacing()); } /** Stamps the shape's non-visual name so tests and users can identify frames. */ @@ -175,10 +177,54 @@ static void setShapeName(XSLFSimpleShape shape, String name) { * kerning entirely. */ private static void disableKerning(XSLFTextRun run) { + characterProperties(run).ifPresent(properties -> properties.setKern(0)); + } + + /** + * Writes tracking as DrawingML's own {@code spc}, never as spaces pushed + * into the text. + * + *

The unit is hundredths of a point, and PowerPoint spends it the same + * way the PDF {@code Tc} operator does. Measured rather than assumed: + * exporting a probe deck through PowerPoint itself and reading the glyph + * positions out of the PDF it wrote, {@code spc="500"} widened every step of + * {@code "JANE"} by 5.0pt — including the step onto a following + * untracked run, which is the trailing unit — and {@code spc="-150"} + * narrowed each by 1.5pt. A single {@code "J"} gained a full unit, and an + * ordinary space was spaced like any other character. One unit per code + * point, trailing included: the same N rule the engine measures with, so a + * line laid out against the PDF measurement arrives on a slide at the width + * it was given.

+ * + *

The conversion is exact, not a rounding: the engine quantises tracking + * to hundredths before it reaches any fixed backend, precisely so the number + * measured and the number declared here are the same one. Rounding again + * costs nothing and keeps this readable as the unit conversion it is. The + * bound the schema puts on {@code spc} — {@code ST_TextPoint} accepts + * {@code 400000} and rejects {@code 400001} — is enforced at that same + * engine seam, so a value that would wrap its sign on the cast below is + * refused before it ever arrives.

+ * + *

Zero writes nothing at all. The attribute's absence is the default, so + * a deck with no tracking in it is byte-identical to one produced before + * this existed.

+ * + * @param run the run being styled + * @param letterSpacing tracking in points, already resolved and quantised + */ + private static void applyLetterSpacing(XSLFTextRun run, double letterSpacing) { + if (letterSpacing == 0.0) { + return; + } + characterProperties(run).ifPresent(properties -> + properties.setSpc((int) Math.round(letterSpacing * 100.0))); + } + + /** The run's character properties, created if the run has none yet. */ + private static Optional characterProperties(XSLFTextRun run) { if (run.getXmlObject() instanceof CTRegularTextRun ctRun) { - CTTextCharacterProperties properties = - ctRun.isSetRPr() ? ctRun.getRPr() : ctRun.addNewRPr(); - properties.setKern(0); + return Optional.of(ctRun.isSetRPr() ? ctRun.getRPr() : ctRun.addNewRPr()); } + return Optional.empty(); } } diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxLetterSpacingTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxLetterSpacingTest.java new file mode 100644 index 000000000..ee8558ff1 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxLetterSpacingTest.java @@ -0,0 +1,214 @@ +package com.demcha.compose.document.backend.fixed.pptx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentLetterSpacing; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.font.FontName; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.apache.poi.xslf.usermodel.XSLFTextParagraph; +import org.apache.poi.xslf.usermodel.XSLFTextRun; +import org.apache.poi.xslf.usermodel.XSLFTextShape; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.drawingml.x2006.main.CTRegularTextRun; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tracking through the PPTX backend, as DrawingML's own {@code spc}. + * + *

The unit and the advance rule were measured by exporting a probe deck + * through real PowerPoint and reading the glyph positions out of the PDF it + * produced: {@code spc} is hundredths of a point, and PowerPoint spends one unit + * per code point with the trailing one included — the same N rule the engine + * measures with, so a line laid out against that measurement arrives at the + * width it was given. What is asserted here is that the backend writes the + * value that measurement calls for, and leaves the text alone.

+ */ +class PptxLetterSpacingTest { + + private static final String NAME = "JANE DOE"; + private static final FontName FAMILY = FontName.LATO; + private static final Pattern SPC = Pattern.compile("spc=\"(-?[0-9]+)\""); + + private static DocumentTextStyle style(DocumentLetterSpacing spacing, double size) { + return DocumentTextStyle.builder() + .fontName(FAMILY).size(size).letterSpacing(spacing).build(); + } + + @Test + void pointsBecomeHundredthsOfAPoint() throws Exception { + // 1.25pt -> 125. The unit is the format's, not the engine's. + assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.points(1.25), 20)))) + .containsExactly(125); + } + + @Test + void aFontSizeShareArrivesResolvedAgainstTheStylesSize() throws Exception { + // 12% of 20pt = 2.4pt -> 240. The share is resolved before the backend + // ever sees it; PPTX is handed points. + assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.ofFontSize(0.12), 20)))) + .containsExactly(240); + } + + @Test + void theSameShareAtADifferentSizeResolvesDifferently() throws Exception { + // 12% of 24pt = 2.88pt -> 288, so the value really does follow the size + // rather than being a constant the backend made up. + assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.ofFontSize(0.12), 24)))) + .containsExactly(288); + } + + @Test + void negativeTrackingIsWrittenAsANegativeValue() throws Exception { + assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.points(-0.75), 20)))) + .containsExactly(-75); + } + + @Test + void noTrackingWritesNoAttributeAtAll() throws Exception { + // Absence is the default, so a deck without tracking carries exactly the + // run properties it carried before this existed. + byte[] pptx = render(NAME, style(DocumentLetterSpacing.NONE, 20)); + + assertThat(spcOf(pptx)).isEmpty(); + assertThat(runsOf(pptx)).isNotEmpty(); + } + + @Test + void aValueBetweenUnitsRoundsToTheNearestHundredth() throws Exception { + // 1/3 pt = 0.3333... -> 33 hundredths. Rounded, not truncated toward + // zero, and the residue is a hundredth of a point. + assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.points(1.0 / 3.0), 20)))) + .containsExactly(33); + } + + @Test + void theTextIsTheAuthorsTextAndNothingElse() throws Exception { + byte[] tracked = render(NAME, style(DocumentLetterSpacing.points(4), 20)); + + // Not "J A N E D O E", and not eight runs of one letter either. + assertThat(textOf(tracked)).isEqualTo(NAME); + } + + @Test + void aTrackedRunAndAnUntrackedOneDoNotAffectEachOther() throws Exception { + // Each PPTX run carries its own rPr, so there is no shared state to + // leak — but that is a claim about the format, and this holds it. + byte[] pptx = renderDocument(page -> page.addParagraph(p -> p + .inlineText("AAAA", style(DocumentLetterSpacing.points(5), 20)) + .inlineText("BBBB", style(DocumentLetterSpacing.NONE, 20)))); + + List runs = runsOf(pptx); + List spacings = new ArrayList<>(); + for (XSLFTextRun run : runs) { + spacings.add(spcOf(run)); + } + assertThat(spacings).containsExactly(500, null); + assertThat(textOf(pptx)).isEqualTo("AAAABBBB"); + } + + @Test + void aMarkdownStyledRunKeepsTheTrackingOfTheParagraphItCameFrom() throws Exception { + // Markdown is parsed in ParagraphWrapping, on the fixed-layout path, and + // it builds each emphasised run by copying components off the paragraph's + // style. Copying four of five would drop the tracking on exactly the + // words an author bothered to emphasise. + byte[] pptx = renderDocument(page -> page.addParagraph(p -> p + .text("PLAIN **BOLD**") + .textStyle(style(DocumentLetterSpacing.points(3), 20)))); + + assertThat(runsOf(pptx)).hasSizeGreaterThan(1); + assertThat(spcOf(pptx)) + .isNotEmpty() + .allMatch(value -> value == 300); + } + + @Test + void aTableCellCarriesTrackingThroughTheSameSeam() throws Exception { + byte[] pptx = renderDocument(page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .defaultCellStyle(com.demcha.compose.document.table.DocumentTableStyle.builder() + .textStyle(style(DocumentLetterSpacing.points(2), 20)).build()) + .row(NAME))); + + assertThat(spcOf(pptx)).contains(200); + assertThat(textOf(pptx)).contains(NAME); + } + + // --- helpers --------------------------------------------------------- + + private static byte[] render(String text, DocumentTextStyle style) throws Exception { + return renderDocument(page -> page.addParagraph(p -> p.text(text).textStyle(style))); + } + + private static byte[] renderDocument( + Consumer body) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(500, 200) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(body); + return session.render(new PptxFixedLayoutBackend()); + } + } + + /** Every {@code spc} actually written, in run order. */ + private static List spcOf(byte[] pptx) throws Exception { + List values = new ArrayList<>(); + for (XSLFTextRun run : runsOf(pptx)) { + Integer spc = spcOf(run); + if (spc != null) { + values.add(spc); + } + } + return values; + } + + /** + * The {@code spc} attribute as the file spells it. + * + *

Read out of the serialized XML rather than through the schema getter, + * which returns the union type {@code Object}: the question this asks is + * what the document says, and the attribute is the answer.

+ */ + private static Integer spcOf(XSLFTextRun run) { + if (!(run.getXmlObject() instanceof CTRegularTextRun ctRun)) { + return null; + } + Matcher matcher = SPC.matcher(ctRun.xmlText()); + return matcher.find() ? Integer.valueOf(matcher.group(1)) : null; + } + + private static List runsOf(byte[] pptx) throws Exception { + List runs = new ArrayList<>(); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + for (XSLFShape shape : show.getSlides().get(0).getShapes()) { + if (shape instanceof XSLFTextShape textShape) { + for (XSLFTextParagraph paragraph : textShape.getTextParagraphs()) { + runs.addAll(paragraph.getTextRuns()); + } + } + } + } + return runs; + } + + private static String textOf(byte[] pptx) throws Exception { + StringBuilder text = new StringBuilder(); + for (XSLFTextRun run : runsOf(pptx)) { + text.append(run.getRawText()); + } + return text.toString(); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/core/identity/Headline.java b/templates/src/main/java/com/demcha/compose/document/templates/core/identity/Headline.java index 2ed21edd1..7af5134ce 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/core/identity/Headline.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/core/identity/Headline.java @@ -14,10 +14,14 @@ *

Variants

* *
    - *
  • {@link #spacedCentered} — centred letter-spaced uppercase - * (e.g. {@code J A N E D O E}). Used by classic / - * editorial presets where the name is the page's visual - * focal point.
  • + *
  • {@link #spacedCentered} — centred uppercase with + * {@link com.demcha.compose.document.templates.core.text.TextOrnaments#SPACED_CAPS} + * tracking. The text stays {@code JANE DOE}; only the type is + * spread. (It used to be spread by rewriting the string to + * {@code J A N E D O E}, which drew the same picture and left + * that in the file for search and copy/paste to find.) Used by + * classic / editorial presets where the name is the page's + * visual focal point.
  • *
  • {@link #uppercaseCentered} — centred uppercase without * extra letter spacing (e.g. {@code JANE DOE}). Used by * compact editorial presets.
  • @@ -189,11 +193,15 @@ public static void render(SectionBuilder host, String name, BrandTheme theme, DocumentTextStyle style = styleOverride != null ? styleOverride : theme.headlineStyle(); - String text = spacedCaps - ? TextOrnaments.spacedUpper(name) - : name; + // Spaced caps are a property of the type, not of the string. The + // tracking goes onto a copy of whatever style the caller handed in, so + // a style also used for ordinary text is not tracked behind its back. + String text = spacedCaps ? TextOrnaments.upper(name) : name; + DocumentTextStyle resolved = spacedCaps + ? style.withLetterSpacing(TextOrnaments.SPACED_CAPS) + : style; - renderText(host, text, theme, alignment, style); + renderText(host, text, theme, alignment, resolved); } private static void renderText(SectionBuilder host, String text, BrandTheme theme, diff --git a/templates/src/main/java/com/demcha/compose/document/templates/core/identity/Subheadline.java b/templates/src/main/java/com/demcha/compose/document/templates/core/identity/Subheadline.java index 6fde920b4..cd7aaebed 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/core/identity/Subheadline.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/core/identity/Subheadline.java @@ -40,26 +40,27 @@ private Subheadline() { } /** - * Centred letter-spaced uppercase subheadline. Text is transformed - * through {@link TextOrnaments#spacedUpper(String)} — pass the raw - * caption ({@code "Professional Title"}) and the widget handles the - * spacing. + * Centred spaced-caps subheadline. Pass the raw caption + * ({@code "Professional Title"}); the widget upper-cases it and puts + * {@link TextOrnaments#SPACED_CAPS} tracking on the style, so what + * reaches the file is the caption and not a padded copy of it. * * @param host host section (typically the same section that * hosts the main {@link Headline}) - * @param text caption text to render, before the spaced-caps + * @param text caption text to render, before the uppercase * transform * @param style explicit text style — the subheadline has no * dedicated theme slot, so the caller composes * {@code font + size + decoration + colour} and hands * it in. Centralise the style in the preset, not at - * each call site. + * each call site. Tracking is added here, to a copy, so + * a style shared with ordinary text keeps its own. */ public static void centeredSpacedCaps(SectionBuilder host, String text, DocumentTextStyle style) { host.addParagraph(p -> p - .text(TextOrnaments.spacedUpper(text)) - .textStyle(style) + .text(TextOrnaments.upper(text)) + .textStyle(style.withLetterSpacing(TextOrnaments.SPACED_CAPS)) .align(TextAlign.CENTER) .margin(DocumentInsets.top(1))); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/core/text/MarkdownText.java b/templates/src/main/java/com/demcha/compose/document/templates/core/text/MarkdownText.java index 6046e4162..948d1b60a 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/core/text/MarkdownText.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/core/text/MarkdownText.java @@ -134,6 +134,11 @@ private static DocumentTextStyle withDecoration(DocumentTextStyle base, Decorati .size(base.size()) .decoration(target) .color(base.color()) + // Emphasis changes the face, not the typography around it. + // Omitting this rebuilt the style from four of its five parts, so + // a bolded word inside a tracked heading came out at a different + // tracking from the words either side of it. + .letterSpacing(base.letterSpacing()) .build(); } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/core/text/TextOrnaments.java b/templates/src/main/java/com/demcha/compose/document/templates/core/text/TextOrnaments.java index 8ed5f625c..0f0608f1c 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/core/text/TextOrnaments.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/core/text/TextOrnaments.java @@ -1,29 +1,100 @@ package com.demcha.compose.document.templates.core.text; +import com.demcha.compose.document.style.DocumentLetterSpacing; + import java.util.Locale; /** - * Pure text-transform helpers used by the v2 components. Currently - * one entry: letter-spaced uppercase rendering for the document - * headline and section banners. + * Pure text-transform helpers used by the v2 components: uppercase + * normalisation for the document headline and section banners, and the + * pipe-joining used for contact lines. + * + *

    The spaced-caps look is no longer built here. It + * used to be: the text was rewritten with a space between every pair of + * letters, which drew the right picture and wrecked the text layer — + * a name came back out of the file as {@code "J A N E D O E"} to + * search, copy/paste, a screen reader and an applicant-tracking + * parser. Spacing is typography, so it now lives on the style as + * {@link #SPACED_CAPS}, and the text stays the text.

    * - *

    These are algorithmic, not cosmetic — they do - * not belong in {@code theme}. The visual effect of "space letters - * apart" is structural: even if a theme picked a different banner - * colour, the letters would still need the same spacing logic.

    + *

    {@link #spacedUpper(String)} is still here and still does exactly + * what it always did, for callers compiled against 2.3.0 and earlier. + * It is deprecated, and no GraphCompose component or preset calls it — + * only the test that pins its output does.

    */ public final class TextOrnaments { + /** + * The tracking the spaced-caps components apply. + * + *

    Chosen against a measurement of what the old transform did. + * That transform put one space glyph between adjacent letters, + * which measured 0.232 em (IBM Plex Serif) to 0.278 em + * (Helvetica) across the faces these presets use — far more + * than editorial spaced caps normally carry, because a space glyph + * is what it had to work with.

    + * + *

    Matching that per-gap figure would have made every heading + * wider than it was, for two reasons: real tracking adds a unit + * after the last glyph as well as between them, and it tracks the + * real word space too. Matching the old total width + * instead puts the value at 0.174–0.209 em on the same + * faces, so 0.18 em sits inside that band, still reads + * unmistakably as spaced caps, and keeps headings near the width + * they already occupied rather than pushing them into new + * wrapping.

    + * + *

    Expressed as a share of the font size, so one value serves a + * 24pt name and an 8pt section label alike — which is what + * the old transform did implicitly, the space glyph scaling with + * the type.

    + */ + public static final DocumentLetterSpacing SPACED_CAPS = DocumentLetterSpacing.ofFontSize(0.18); + private TextOrnaments() { } + /** + * Uppercase rendering, with {@code null} treated as empty. + * + *

    The text and nothing but the text: pair it with + * {@link #SPACED_CAPS} on the style when the spaced-caps look is + * wanted.

    + * + * @param value source text (null tolerated, returned as empty) + * @return the value in upper case + */ + public static String upper(String value) { + if (value == null) { + return ""; + } + return value.toUpperCase(Locale.ROOT); + } + /** * Letter-spaced uppercase rendering (e.g. * {@code spacedUpper("Jane Doe") -> "J A N E D O E"}). * + *

    Unchanged from 2.3.0, character for character, and kept so + * that code written against it keeps compiling and keeps producing + * the same strings. No built-in preset calls it any more.

    + * * @param value source text (null tolerated, returned as empty) * @return spaced-caps representation + * @deprecated since 2.4.0; removed in 3.0. Use {@link #upper(String)} + * for the text and carry the spacing on the style instead + * — {@link #SPACED_CAPS}, or any + * {@link DocumentLetterSpacing} you prefer. The reason is + * not style: padding the string is what stored a name as + * {@code "J A N E D O E"}, so the one field a CV is + * searched and parsed by came back out of the file + * unreadable. The replacement draws spaced caps and leaves + * the text alone. It does not reproduce this method's + * metrics exactly — a whole space glyph per gap is + * wider than editorial tracking — so expect the same + * look at a slightly different width. */ + @Deprecated(since = "2.4.0", forRemoval = true) public static String spacedUpper(String value) { if (value == null) { return ""; diff --git a/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/MintEditorialLetter.java b/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/MintEditorialLetter.java index 0d6d4ffaf..d9430572e 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/MintEditorialLetter.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/MintEditorialLetter.java @@ -359,7 +359,7 @@ private void addBandedMasthead(SectionBuilder section, .build(); ParagraphNode name = new ParagraphBuilder() .name("CoverLetterV2MintEditorialHeaderName") - .text(TextOrnaments.spacedUpper(identity.name().full())) + .text(TextOrnaments.upper(identity.name().full())) .textStyle(mastheadNameStyle()) .align(TextAlign.CENTER) .build(); @@ -367,7 +367,7 @@ private void addBandedMasthead(SectionBuilder section, ParagraphNode tagline = jobTitle != null && !jobTitle.isBlank() ? new ParagraphBuilder() .name("CoverLetterV2MintEditorialHeaderTagline") - .text(TextOrnaments.spacedUpper(jobTitle)) + .text(TextOrnaments.upper(jobTitle)) .textStyle(taglineStyle()) .align(TextAlign.CENTER) .build() @@ -387,16 +387,20 @@ private void addBandedMasthead(SectionBuilder section, }); } + /** Spaced caps: only the masthead name uses this. */ private DocumentTextStyle mastheadNameStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeHeadline(), - DocumentTextDecoration.DEFAULT, nameColor); + DocumentTextDecoration.DEFAULT, nameColor) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } + /** Spaced caps: only the tagline uses this. */ private DocumentTextStyle taglineStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeContact(), - DocumentTextDecoration.BOLD, accent); + DocumentTextDecoration.BOLD, accent) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/MonogramSidebarLetter.java b/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/MonogramSidebarLetter.java index 0fc1fce11..5a3f6e7c2 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/MonogramSidebarLetter.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/MonogramSidebarLetter.java @@ -197,7 +197,7 @@ private void addNameBlock(SectionBuilder section, CvIdentity identity) { ? DocumentInsets.zero() : DocumentInsets.bottom(6); section.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(part)) + .text(TextOrnaments.upper(part)) .textStyle(nameStyle) .align(TextAlign.CENTER) .lineSpacing(1.0) @@ -206,23 +206,27 @@ private void addNameBlock(SectionBuilder section, CvIdentity identity) { String jobTitle = identity.jobTitle(); if (jobTitle != null && !jobTitle.isBlank()) { section.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(jobTitle)) + .text(TextOrnaments.upper(jobTitle)) .textStyle(titleStyle) .align(TextAlign.CENTER) .margin(new DocumentInsets(12, 0, 18, 0))); } } + /** Spaced caps: only the monogram name lines use this. */ private DocumentTextStyle nameStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeHeadline(), - DocumentTextDecoration.DEFAULT, theme.palette().ink()); + DocumentTextDecoration.DEFAULT, theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } + /** Spaced caps: only the job-title subtitle uses this. */ private DocumentTextStyle subtitleStyle() { return TextStyles.of(theme.typography().bodyFont(), theme.typography().sizeContact(), - DocumentTextDecoration.BOLD, ACCENT); + DocumentTextDecoration.BOLD, ACCENT) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle contactMetaStyle() { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/SidebarPortraitLetter.java b/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/SidebarPortraitLetter.java index 916212883..c808e431a 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/SidebarPortraitLetter.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/SidebarPortraitLetter.java @@ -108,7 +108,7 @@ private void addHeroBand(PageFlowBuilder flow, CvIdentity identity) { String jobTitle = identity.jobTitle(); String subline = jobTitle == null || jobTitle.isBlank() ? "" - : TextOrnaments.spacedUpper(jobTitle); + : TextOrnaments.upper(jobTitle); flow.addSection("CoverLetterV2SidebarPortraitHero", hero -> { // No fill: the CV's beige hero band reads as a coloured box // on a single-column letter, which clashed with the concept, @@ -143,10 +143,12 @@ private DocumentTextStyle nameStyle() { DocumentTextDecoration.BOLD, theme.palette().ink()); } + /** Spaced caps: only the hero subline uses this. */ private DocumentTextStyle subtitleStyle() { return TextStyles.of(theme.typography().bodyFont(), theme.typography().sizeEntryDate(), - DocumentTextDecoration.DEFAULT, theme.palette().ink()); + DocumentTextDecoration.DEFAULT, theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle contactStyle() { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/TimelineMinimalLetter.java b/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/TimelineMinimalLetter.java index f8aceb090..24017b040 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/TimelineMinimalLetter.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/coverletter/presets/TimelineMinimalLetter.java @@ -141,7 +141,7 @@ public void compose(DocumentSession document, CoverLetterDocument doc) { private void addNameBlock(SectionBuilder section, CvIdentity identity) { section.spacing(4) .addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(identity.name().full())) + .text(TextOrnaments.upper(identity.name().full())) .textStyle(nameStyle()) .margin(DocumentInsets.zero())); String jobTitle = identity.jobTitle(); @@ -210,10 +210,12 @@ private SvgGlyph glyph(String iconFile) { return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile); } + /** Spaced caps: only the name block uses this. */ private DocumentTextStyle nameStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeHeadline(), - DocumentTextDecoration.DEFAULT, theme.palette().ink()); + DocumentTextDecoration.DEFAULT, theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle jobTitleStyle() { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java index e4dbab8da..9e864ed61 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java @@ -317,17 +317,19 @@ private void renderTightKeyValue(SectionBuilder host, CvRow row) { private void addCenteredTitle(SectionBuilder host, String title) { host.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(title)) + .text(TextOrnaments.upper(title)) .textStyle(titleStyle()) .align(TextAlign.CENTER) .margin(DocumentInsets.zero())); } + /** Spaced caps: both section-title shapes of this preset use it. */ private DocumentTextStyle titleStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeBanner(), DocumentTextDecoration.BOLD, - ACCENT); + ACCENT) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle contactMetaStyle() { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java index 2105d3011..676f05ab9 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java @@ -650,7 +650,7 @@ private void addBandedHeader(SectionBuilder section, CvIdentity identity, .build(); ParagraphNode name = new ParagraphBuilder() .name("CvV2MintEditorialHeaderName") - .text(TextOrnaments.spacedUpper(identity.name().full())) + .text(TextOrnaments.upper(identity.name().full())) .textStyle(mastheadNameStyle()) .align(TextAlign.CENTER) .build(); @@ -659,7 +659,7 @@ private void addBandedHeader(SectionBuilder section, CvIdentity identity, ParagraphNode tagline = hasTagline ? new ParagraphBuilder() .name("CvV2MintEditorialHeaderTagline") - .text(TextOrnaments.spacedUpper(jobTitle)) + .text(TextOrnaments.upper(jobTitle)) .textStyle(taglineStyle()) .align(TextAlign.CENTER) .build() @@ -749,7 +749,7 @@ private void addEducation(SectionBuilder section, CvSection education) { section.addSection("CvV2MintEditorialEducation", block -> { block.spacing(0).padding(DocumentInsets.zero()); addBlockHeading(block, education.title()); - DocumentTextStyle degreeStyle = labelStyle(); + DocumentTextStyle degreeStyle = labelStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS); DocumentTextStyle metaStyle = smallStyle(); for (CvEntry entry : entries) { block.addParagraph(p -> p @@ -757,7 +757,7 @@ private void addEducation(SectionBuilder section, CvSection education) { .align(TextAlign.LEFT) .margin(DocumentInsets.bottom(5)) .rich(rich -> MarkdownInline.appendTransformed(rich, entry.title(), - degreeStyle, TextOrnaments::spacedUpper))); + degreeStyle, TextOrnaments::upper))); if (!entry.subtitle().isBlank()) { block.addParagraph(p -> p .textStyle(metaStyle) @@ -904,7 +904,7 @@ private void addExperience(SectionBuilder section, String title, section.addSection("CvV2MintEditorialExperience", block -> { block.spacing(0).padding(DocumentInsets.zero()); addBlockHeading(block, title); - DocumentTextStyle titleStyle = labelStyle(); + DocumentTextStyle titleStyle = labelStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS); DocumentTextStyle metaStyle = smallStyle(); DocumentTextStyle bodyStyle = bodyStyle(); for (CvEntry entry : entries) { @@ -913,7 +913,7 @@ private void addExperience(SectionBuilder section, String title, .align(TextAlign.LEFT) .margin(DocumentInsets.bottom(5)) .rich(rich -> MarkdownInline.appendTransformed(rich, entry.title(), - titleStyle, TextOrnaments::spacedUpper))); + titleStyle, TextOrnaments::upper))); String meta = composeMeta(entry); if (!meta.isBlank()) { block.addParagraph(p -> p @@ -958,8 +958,8 @@ private void addAwards(SectionBuilder section, CvSection awards, return; } List entries = rows.rows(); - DocumentTableStyle nameLeft = cellStyle(labelStyle(), 4, GRID_COLUMN_GAP); - DocumentTableStyle nameRight = cellStyle(labelStyle(), 4, 0); + DocumentTableStyle nameLeft = cellStyle(labelStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS), 4, GRID_COLUMN_GAP); + DocumentTableStyle nameRight = cellStyle(labelStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS), 4, 0); DocumentTableStyle metaLeft = cellStyle(smallStyle(), 18, GRID_COLUMN_GAP); DocumentTableStyle metaRight = cellStyle(smallStyle(), 18, 0); DocumentTableStyle metaLeftLast = cellStyle(smallStyle(), 0, GRID_COLUMN_GAP); @@ -982,9 +982,9 @@ private void addAwards(SectionBuilder section, CvSection awards, : null; boolean lastPair = pairIndex == pairs - 1; table.rowCells( - gridCell(TextOrnaments.spacedUpper( + gridCell(TextOrnaments.upper( MarkdownInline.plainText(left.label())), nameLeft), - gridCell(right == null ? "" : TextOrnaments.spacedUpper( + gridCell(right == null ? "" : TextOrnaments.upper( MarkdownInline.plainText(right.label())), nameRight)); table.rowCells( gridCell(MarkdownInline.plainText(left.body()), @@ -1004,8 +1004,8 @@ private void addReferences(SectionBuilder section, CvSection references, return; } List entries = rows.rows(); - DocumentTableStyle nameLeft = cellStyle(labelStyle(), 4, GRID_COLUMN_GAP); - DocumentTableStyle nameRight = cellStyle(labelStyle(), 4, 0); + DocumentTableStyle nameLeft = cellStyle(labelStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS), 4, GRID_COLUMN_GAP); + DocumentTableStyle nameRight = cellStyle(labelStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS), 4, 0); DocumentTableStyle subLeft = cellStyle(smallStyle(), 3, GRID_COLUMN_GAP); DocumentTableStyle subRight = cellStyle(smallStyle(), 3, 0); DocumentTableStyle lastLeft = cellStyle(smallStyle(), 18, GRID_COLUMN_GAP); @@ -1031,9 +1031,9 @@ private void addReferences(SectionBuilder section, CvSection references, boolean lastPair = pairIndex == pairs - 1; // Name row (spaced-caps bold). table.rowCells( - gridCell(TextOrnaments.spacedUpper( + gridCell(TextOrnaments.upper( MarkdownInline.plainText(left.label())), nameLeft), - gridCell(right == null ? "" : TextOrnaments.spacedUpper( + gridCell(right == null ? "" : TextOrnaments.upper( MarkdownInline.plainText(right.label())), nameRight)); // One table row per body line (Company / "P: phone" / // email). The email line becomes a clickable mailto via @@ -1110,7 +1110,7 @@ private void addBlockHeading(SectionBuilder block, String title) { return; } block.addParagraph(p -> p - .text(TextOrnaments.spacedUpper(title)) + .text(TextOrnaments.upper(title)) .textStyle(headingStyle()) .align(TextAlign.LEFT) .margin(DocumentInsets.bottom(18))); @@ -1121,8 +1121,8 @@ private void addLabel(SectionBuilder section, String text) { return; } section.addParagraph(p -> p - .text(TextOrnaments.spacedUpper(text)) - .textStyle(labelStyle()) + .text(TextOrnaments.upper(text)) + .textStyle(labelStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS)) .align(TextAlign.LEFT) .margin(DocumentInsets.bottom(14))); } @@ -1142,21 +1142,30 @@ private DocumentTableCell gridCell(String text, DocumentTableStyle style) { private DocumentTextStyle mastheadNameStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeHeadline(), - DocumentTextDecoration.DEFAULT, nameColor); + DocumentTextDecoration.DEFAULT, nameColor) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle taglineStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeContact(), - DocumentTextDecoration.BOLD, accent); + DocumentTextDecoration.BOLD, accent) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle headingStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeBanner(), - DocumentTextDecoration.BOLD, accent); + DocumentTextDecoration.BOLD, accent) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } + /** + * Deliberately untracked. Most of its users are spaced caps and + * ask for {@link TextOrnaments#SPACED_CAPS} themselves, but the + * social-link row renders ordinary label text with it — tracking it here + * would space out text nobody asked to space. + */ private DocumentTextStyle labelStyle() { return TextStyles.of(theme.typography().bodyFont(), theme.typography().sizeEntryTitle(), diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java index af6838959..bab7d77fb 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java @@ -463,7 +463,7 @@ private void addSidebarHeader(SectionBuilder section, String title, return; } section.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(title)) + .text(TextOrnaments.upper(title)) .textStyle(sidebarHeaderStyle()) .align(TextAlign.CENTER) .lineSpacing(1.2) @@ -677,14 +677,14 @@ private void addNameBlock(SectionBuilder section, CvIdentity identity) { ? DocumentInsets.zero() : DocumentInsets.bottom(6); section.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(part)) + .text(TextOrnaments.upper(part)) .textStyle(nameStyle) .align(TextAlign.CENTER) .lineSpacing(1.0) .margin(margin)); } section.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(subline)) + .text(TextOrnaments.upper(subline)) .textStyle(titleStyle) .align(TextAlign.CENTER) .margin(new DocumentInsets(12, 0, 22, 0))); @@ -695,7 +695,7 @@ private void addMainSectionHeader(SectionBuilder section, String title) { return; } section.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(title)) + .text(TextOrnaments.upper(title)) .textStyle(mainHeaderStyle()) .align(TextAlign.LEFT) .margin(DocumentInsets.top(6))); @@ -731,7 +731,7 @@ private void addExperienceEntries(SectionBuilder section, } DocumentTextStyle positionStyle = mainEntryTitleStyle(); DocumentTextStyle employerStyle = theme.entrySubtitleStyle(); - DocumentTextStyle dateStyle = mainEntryDateStyle(); + DocumentTextStyle dateStyle = mainEntryDateStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS); DocumentTextStyle bodyStyle = mainBodyStyle(); List list = entries.entries(); @@ -756,7 +756,7 @@ private void addExperienceEntries(SectionBuilder section, } if (!entry.date().isBlank()) { section.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper( + .text(TextOrnaments.upper( MarkdownInline.plainText(entry.date()))) .textStyle(dateStyle) .align(TextAlign.LEFT) @@ -781,21 +781,24 @@ private DocumentTextStyle nameStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeHeadline(), DocumentTextDecoration.DEFAULT, - theme.palette().ink()); + theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle subtitleStyle() { return TextStyles.of(theme.typography().bodyFont(), theme.typography().sizeContact(), DocumentTextDecoration.BOLD, - accent); + accent) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle sidebarHeaderStyle() { return TextStyles.of(theme.typography().bodyFont(), 8.0, DocumentTextDecoration.BOLD, - theme.palette().ink()); + theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle sidebarBodyStyle() { @@ -837,7 +840,8 @@ private DocumentTextStyle mainHeaderStyle() { return TextStyles.of(theme.typography().bodyFont(), theme.typography().sizeBanner(), DocumentTextDecoration.BOLD, - theme.palette().ink()); + theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle mainBodyStyle() { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java index 994b612bf..6f09fdabf 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java @@ -14,6 +14,7 @@ import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.svg.SvgIcon; import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.core.text.TextOrnaments; import com.demcha.compose.document.templates.core.text.TextStyles; import com.demcha.compose.document.templates.core.text.MarkdownInline; import com.demcha.compose.document.templates.cv.components.ProjectLabel; @@ -515,7 +516,7 @@ private void addSidebarHeader(SectionBuilder section, String title) { .thickness(0.75) .margin(new DocumentInsets(12, 0, 7, 0))); section.addParagraph(paragraph -> paragraph - .text(spacedUpper(title)) + .text(TextOrnaments.upper(title)) .textStyle(sidebarHeaderStyle()) .align(TextAlign.LEFT) .margin(DocumentInsets.zero())); @@ -634,7 +635,7 @@ private void addNameBlock(SectionBuilder section, CvIdentity identity) { .lineSpacing(1.0) .margin(DocumentInsets.zero())) .addParagraph(paragraph -> paragraph - .text(spacedUpper(subline)) + .text(TextOrnaments.upper(subline)) .textStyle(subtitleStyle()) .align(TextAlign.CENTER) .margin(DocumentInsets.zero()))); @@ -673,7 +674,7 @@ private void addMainSectionHeader(SectionBuilder section, String title) { return; } section.addParagraph(paragraph -> paragraph - .text(spacedUpper(title)) + .text(TextOrnaments.upper(title)) .textStyle(mainHeaderStyle()) .align(TextAlign.LEFT) .margin(DocumentInsets.top(8))); @@ -820,7 +821,8 @@ private DocumentTextStyle subtitleStyle() { return TextStyles.of(theme.typography().bodyFont(), theme.typography().sizeEntryDate(), DocumentTextDecoration.DEFAULT, - theme.palette().ink()); + theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle contactStyle() { @@ -834,7 +836,8 @@ private DocumentTextStyle sidebarHeaderStyle() { return TextStyles.of(theme.typography().bodyFont(), 10.8, DocumentTextDecoration.BOLD, - theme.palette().ink()); + theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle sidebarEntryTitleStyle() { @@ -876,7 +879,8 @@ private DocumentTextStyle mainHeaderStyle() { return TextStyles.of(theme.typography().bodyFont(), theme.typography().sizeBanner(), DocumentTextDecoration.BOLD, - theme.palette().ink()); + theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle mainBodyStyle() { @@ -1053,23 +1057,6 @@ private static List languageItems(CvSection section) { return result; } - private static String spacedUpper(String value) { - String upper = (value == null ? "" : value).toUpperCase(Locale.ROOT); - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < upper.length(); i++) { - char current = upper.charAt(i); - builder.append(current); - if (Character.isLetterOrDigit(current) - && i + 1 < upper.length() - && Character.isLetterOrDigit(upper.charAt(i + 1))) { - builder.append(' '); - } else if (Character.isWhitespace(current)) { - builder.append(" "); - } - } - return builder.toString(); - } - private record ContactItem(String iconFile, String text, DocumentLinkOptions linkOptions) { } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java index 8f03cc54f..2e0c2d2b7 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java @@ -15,6 +15,7 @@ import com.demcha.compose.document.style.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.core.text.TextOrnaments; import com.demcha.compose.document.templates.core.text.TextStyles; import com.demcha.compose.document.templates.core.text.MarkdownInline; import com.demcha.compose.document.templates.cv.components.SectionAllocation; @@ -329,7 +330,7 @@ public void compose(DocumentSession document, CvDocument doc) { private void addNameBlock(SectionBuilder section, CvIdentity identity) { section.spacing(NAME_BLOCK_GAP) .addParagraph(paragraph -> paragraph - .text(spacedUpper(identity.name().full())) + .text(TextOrnaments.upper(identity.name().full())) .textStyle(nameStyle()) .margin(DocumentInsets.zero())); String jobTitle = identity.jobTitle(); @@ -556,11 +557,13 @@ private void addMainModule(SectionBuilder main, ColumnPagination.Block module) { // -- style factories --------------------------------------------- + /** Spaced caps: only the name block uses this. */ private DocumentTextStyle nameStyle() { return TextStyles.of(theme.typography().headlineFont(), theme.typography().sizeHeadline(), DocumentTextDecoration.DEFAULT, - theme.palette().ink()); + theme.palette().ink()) + .withLetterSpacing(TextOrnaments.SPACED_CAPS); } private DocumentTextStyle jobTitleStyle() { @@ -742,23 +745,6 @@ private static String pickFallbackIcon(String label) { return "@"; } - private static String spacedUpper(String value) { - String upper = safe(value).toUpperCase(Locale.ROOT); - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < upper.length(); i++) { - char current = upper.charAt(i); - builder.append(current); - if (Character.isWhitespace(current)) { - builder.append(" "); - } - if (Character.isLetter(current) && i + 1 < upper.length() - && Character.isLetter(upper.charAt(i + 1))) { - builder.append(' '); - } - } - return builder.toString(); - } - private static String safe(String value) { return value == null ? "" : value; } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SectionHeader.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SectionHeader.java index f74436aee..fa4a4e6b6 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SectionHeader.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SectionHeader.java @@ -76,8 +76,8 @@ public static void banner(SectionBuilder host, String title, BrandTheme theme) { theme.spacing().bannerInnerPadding()) .margin(theme.spacing().bannerMargin()) .addParagraph(p -> p - .text(TextOrnaments.spacedUpper(title)) - .textStyle(theme.bannerStyle()) + .text(TextOrnaments.upper(title)) + .textStyle(theme.bannerStyle().withLetterSpacing(TextOrnaments.SPACED_CAPS)) .align(TextAlign.CENTER) .margin(DocumentInsets.zero())); } @@ -118,8 +118,8 @@ public static void fullWidthBanner(SectionBuilder host, String title, 0, theme.spacing().bannerInnerPadding(), 0)) .margin(theme.spacing().bannerMargin()) .addParagraph(p -> p - .text(TextOrnaments.spacedUpper(title)) - .textStyle(titleStyle) + .text(TextOrnaments.upper(title)) + .textStyle(titleStyle.withLetterSpacing(TextOrnaments.SPACED_CAPS)) .align(TextAlign.CENTER) .margin(DocumentInsets.zero())); } @@ -138,8 +138,8 @@ public static void underlined(SectionBuilder host, String title, BrandTheme them theme.spacing().accentRuleWidth()) .padding(new DocumentInsets(8, 0, 2, 0)) .addParagraph(p -> p - .text(TextOrnaments.spacedUpper(title)) - .textStyle(titleStyle) + .text(TextOrnaments.upper(title)) + .textStyle(titleStyle.withLetterSpacing(TextOrnaments.SPACED_CAPS)) .align(TextAlign.LEFT) .margin(DocumentInsets.zero())); } @@ -175,7 +175,7 @@ public static void flat(SectionBuilder host, String title, * Small left-aligned spaced-caps bold title in a given colour. No * panel, no rule — flat like {@link #flat} but typographically * quieter: body font, body-sized, transformed to letter-spaced - * uppercase via {@link TextOrnaments#spacedUpper(String)}. Visual + * uppercase with {@link TextOrnaments#SPACED_CAPS} tracking. Visual * signature of {@code CenteredHeadline}. * *

    If the {@code titleStyle} parameter is {@code null} the widget @@ -206,8 +206,8 @@ public static void flatSpacedCaps(SectionBuilder host, String title, .build(); host.padding(new DocumentInsets(0, 0, 0, 0)) .addParagraph(p -> p - .text(TextOrnaments.spacedUpper(title)) - .textStyle(resolved) + .text(TextOrnaments.upper(title)) + .textStyle(resolved.withLetterSpacing(TextOrnaments.SPACED_CAPS)) .align(TextAlign.LEFT) .margin(DocumentInsets.zero())); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SkillBar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SkillBar.java index e1d8f2c6e..286b0ca60 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SkillBar.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SkillBar.java @@ -23,7 +23,7 @@ * *

      *
    1. the skill name in letter-spaced uppercase (bold, ink), via - * {@link TextOrnaments#spacedUpper(String)};
    2. + * {@link TextOrnaments#SPACED_CAPS} tracking; *
    3. a horizontal track line spanning {@code trackWidth} in the theme * rule colour, overlaid with a short vertical marker (ink) whose * left offset is {@code level * trackWidth} — so a higher @@ -118,8 +118,8 @@ public static void render(SectionBuilder host, CvSkill skill, // little breathing room below to match the rhythm of a barred entry. double labelBottom = levelled ? LABEL_TO_TRACK_GAP : BAR_BOTTOM_GAP; host.addParagraph(paragraph -> paragraph - .text(TextOrnaments.spacedUpper(skill.name())) - .textStyle(labelStyle) + .text(TextOrnaments.upper(skill.name())) + .textStyle(labelStyle.withLetterSpacing(TextOrnaments.SPACED_CAPS)) .align(TextAlign.LEFT) .margin(DocumentInsets.bottom(labelBottom))); diff --git a/templates/src/test/java/com/demcha/compose/document/templates/core/text/TextOrnamentsSpacedUpperCompatibilityTest.java b/templates/src/test/java/com/demcha/compose/document/templates/core/text/TextOrnamentsSpacedUpperCompatibilityTest.java new file mode 100644 index 000000000..28e16d43b --- /dev/null +++ b/templates/src/test/java/com/demcha/compose/document/templates/core/text/TextOrnamentsSpacedUpperCompatibilityTest.java @@ -0,0 +1,94 @@ +package com.demcha.compose.document.templates.core.text; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins {@link TextOrnaments#spacedUpper(String)} to the exact strings it + * produced in 2.3.0, character for character. + * + *

      The method is deprecated and no built-in preset calls it any more, + * which is precisely why it needs this: a deprecated method with no + * callers inside the project is one nobody would notice breaking. It is + * published Stable API in {@code graph-compose-templates}, so until a + * major release removes it, "still compiles" is not enough — it has + * to keep returning the same strings.

      + * + *

      These are not the outputs the replacement produces. Real tracking + * spreads letters without touching the string, so + * {@link TextOrnaments#upper(String)} returns {@code "JANE DOE"} where + * this returns {@code "J A N E D O E"}. That difference is the whole + * point of the deprecation; the assertions below deliberately describe + * the old behaviour.

      + */ +@SuppressWarnings({"deprecation", "removal"}) +class TextOrnamentsSpacedUpperCompatibilityTest { + + @Test + void nullBecomesEmptyRatherThanThrowing() { + assertThat(TextOrnaments.spacedUpper(null)).isEmpty(); + } + + @Test + void emptyStaysEmpty() { + assertThat(TextOrnaments.spacedUpper("")).isEmpty(); + } + + @Test + void lettersAreSeparatedBySingleSpacesAndWordsByThree() { + // One space between adjacent letters; the real word space keeps + // itself and gains two more, so words read as separated. + assertThat(TextOrnaments.spacedUpper("Jane Doe")).isEqualTo("J A N E D O E"); + } + + @Test + void digitsSpaceLikeLetters() { + // isLetterOrDigit, so "R2" spaces between R and 2, and a digit + // adjacent to a letter gets the same treatment either way round. + assertThat(TextOrnaments.spacedUpper("R2 D2")).isEqualTo("R 2 D 2"); + assertThat(TextOrnaments.spacedUpper("A1B2")).isEqualTo("A 1 B 2"); + } + + @Test + void punctuationBreaksTheRunAndIsNeverPaddedOnEitherSide() { + // The space is emitted only when BOTH the current and the next + // character are letters or digits, so punctuation sits tight + // against its neighbours on both sides. + assertThat(TextOrnaments.spacedUpper("O'Neill-Smith")) + .isEqualTo("O'N E I L L-S M I T H"); + assertThat(TextOrnaments.spacedUpper("A.B")).isEqualTo("A.B"); + assertThat(TextOrnaments.spacedUpper("C++")).isEqualTo("C++"); + } + + @Test + void everyWhitespaceCharacterKeepsItselfAndGainsTwoMore() { + assertThat(TextOrnaments.spacedUpper("A B")).isEqualTo("A B"); + // Each whitespace character is expanded independently, so a double + // space becomes six. + assertThat(TextOrnaments.spacedUpper("A B")).isEqualTo("A B"); + // Any Character.isWhitespace, not just the space glyph. + assertThat(TextOrnaments.spacedUpper("A\tB")).isEqualTo("A\t B"); + assertThat(TextOrnaments.spacedUpper("A\nB")).isEqualTo("A\n B"); + } + + @Test + void leadingAndTrailingWhitespaceIsExpandedRatherThanTrimmed() { + assertThat(TextOrnaments.spacedUpper(" A ")).isEqualTo(" A "); + } + + @Test + void aTrailingLetterOrDigitGetsNoSpaceAfterIt() { + // The trailing unit is exactly what real tracking adds and this + // transform does not — the reason 0.18em matches the old total + // width rather than the old per-gap width. + assertThat(TextOrnaments.spacedUpper("AB")).isEqualTo("A B"); + assertThat(TextOrnaments.spacedUpper("A")).isEqualTo("A"); + } + + @Test + void theReplacementDeliberatelyDoesNotReproduceThis() { + assertThat(TextOrnaments.upper("Jane Doe")).isEqualTo("JANE DOE"); + assertThat(TextOrnaments.spacedUpper("Jane Doe")).isEqualTo("J A N E D O E"); + } +}