Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Binary file modified assets/readme/examples/cover-letter-blue-banner-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-boxed-sections-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-centered-headline-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-classic-serif-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-mint-editorial-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-timeline-minimal-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-blue-banner-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-boxed-sections-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-centered-headline-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-classic-serif-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-minimal-underlined-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-mint-editorial-v2-custom.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-mint-editorial-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-monogram-sidebar-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-sidebar-portrait-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-timeline-minimal-v2.pdf
Binary file not shown.
Binary file added assets/readme/examples/letter-spacing.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>Set by DrawingML, the least capacious of the fixed backends: {@code spc}
* is {@code ST_TextPoint}, whose numeric member is bounded at
* &plusmn;400000 hundredths. Measured, not read &mdash; the schema validates
* {@code 400000} and rejects {@code 400001}.</p>
*/
static final double MAX_FIXED_LAYOUT_TRACKING_POINTS = 4000.0;

/**
* Tracking as fixed layout can actually express it: quantised to hundredths
* of a point.
*
* <p>This exists because the engine's measurement and the file's declared
* spacing have to be the <em>same number</em>, 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.</p>
*
* <p>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 &mdash; 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.</p>
*
* <p>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 &mdash; and correctly so, because Word owns that layout and
* owes the PDF no coordinate.</p>
*
* <p>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 &mdash; {@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.</p>
*
* @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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.demcha.compose.document.style;

/**
* Letter spacing (typographic <em>tracking</em>) for a
* {@link DocumentTextStyle} &mdash; extra advance inserted after every rendered
* code point, expressed either in absolute points or as a fraction of the font
* size.
*
* <p>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 &mdash; the
* shape {@code "J A N E D O E"} &mdash; achieves the same picture and breaks
* every one of those.</p>
*
* <p>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 &mdash; {@code ofFontSize(0.12)} and
* {@code points(1.2)} are both plausible-looking numbers and a bare
* {@code double} could not tell them apart.</p>
*
* <p>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.</p>
*
* <h2>What survives into a file</h2>
*
* <p>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
* <em>file formats</em> are what quantise, and they do it differently:</p>
*
* <ul>
* <li><strong>PDF and PPTX &mdash; 0.01pt.</strong> DrawingML states spacing
* in hundredths of a point, so that is the finest distinction the two
* fixed-layout formats can both make. The engine measures on that grid,
* which is what keeps the width it reserves and wraps against equal to
* the width the file draws. Ask for a third of a point and both get
* {@code 0.33}.</li>
* <li><strong>DOCX &mdash; 0.05pt.</strong> Word states spacing in twentieths
* of a point, and the export rounds this value to that grid on its own.
* Word owns its layout, so it is not held to the hundredth the fixed
* backends settled on.</li>
* </ul>
*
* <p>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.</p>
*
* <p>Tracking larger than a format can state is refused when the document is
* rendered, rather than wrapped into a negative &mdash; fixed layout tops out at
* &plusmn;4000pt, DrawingML's own bound. The limits belong to the formats; this
* value accepts any finite number.</p>
*
* <pre>{@code
* DocumentTextStyle headline = DocumentTextStyle.builder()
* .size(24)
* .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12)) // 12% of 24pt = 2.88pt
* .build();
* }</pre>
*
* @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 &mdash; 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.
*
* <p>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.</p>
*
* @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;
}
}
Loading
Loading