Skip to content

Support for character-level justification#6161

Merged
laurmaedje merged 27 commits intotypst:mainfrom
diquah:main
Oct 8, 2025
Merged

Support for character-level justification#6161
laurmaedje merged 27 commits intotypst:mainfrom
diquah:main

Conversation

@diquah
Copy link
Copy Markdown
Contributor

@diquah diquah commented Apr 10, 2025

This implements a partial solution to #4693.

This PR adds a new property, microjustification, to par. Microjustification allows kerning to be dynamically applied during justification to further justify an existing line. This added kerning will never exceed microjustification, but may be less depending on how justified the line already is. This is equivalent to "Letter Spacing" in Adobe InDesign, and it improves readability by creating more uniform blocks of text, especially for very narrow blocks.

All glyphs have microjustification enabled by default, but this can be changed in the future.

Example

#table(
    stroke: none,
    gutter: 1em,
    columns: 3,
)[
    *Normal Justification*
][
    *Normal + Micro Justification*
][
    *Normal + Excessive Micro Justification*
][
    // Microjustification is disabled (0em) by default.
    #set par(justify: true)
    #lorem(100)
][
    // Microtype uses 2% as a sensible default.
    #set par(justify: true, microjustification: 0.025em)
    #lorem(100)
][
    #set par(justify: true, microjustification: 0.1em)
    #lorem(100)
]
image

Notice that lines with excessively large spaces become more readable.

Limitations

  • Microjustification occurs only after a line has been initially drafted. This means microjustification will not change the layout of a paragraph. It only further justifies existing lines by adding spacing between glyphs.
  • This is not a complete solution to Automatic font expansion #4693 as that issue requests glyph expansion like microtype, but that is only one element of microtypography. Both letter spacing and expansion contribute to readability, and glyph scaling can be implemented in a later PR. The scope of this PR is dynamic kerning for justification.

@MDLC01
Copy link
Copy Markdown
Collaborator

MDLC01 commented Apr 10, 2025

How doe this work with ligatures? It looks like they were disabled in the example.

@diquah
Copy link
Copy Markdown
Contributor Author

diquah commented Apr 10, 2025

@MDLC01 They appear to be treated as a single glyph, which makes sense.

image

(Testing with fi)

I'm not sure what the expected behavior should be when both are enabled, but note that in this example the microjustification is what I would consider excessive. You could tastefully mix the two by using less microjustification. I think it makes sense to allow both to be enabled at the same time, which empowers the user to choose if this is acceptable behavior or not.

@Enivex
Copy link
Copy Markdown
Collaborator

Enivex commented Apr 10, 2025

Regarding the first limitation, I would argue that one of the major benefits of microtypography is avoiding hyphenation in the first place. By only considering tracking, kerning expansion and protrusion only after line breaking has been done, we lose out on this.

As an aside, is this really a good name for this feature?

In fact, this seems to be orthogonal to justification. Adjusting tracking can be used to lessen "raggedness" even without actual justification

@diquah
Copy link
Copy Markdown
Contributor Author

diquah commented Apr 11, 2025

@Enivex I agree with your first point. It's not a perfect solution, but I had trouble with having the microtype change the layout, because the layout engine seems to prefer microtype over hyphenation in the vast majority of cases (including some I would have personally prevented). I'll keep at it and see if I can get something working.

As to the naming, assuming all microtype justification features have been implemented, what are your thoughts on having a parameter in par named microtype with the following parameters:

  • max_shrink: How much a glyph is allowed to be scaled horizontally in the negative direction for justification.
  • max_stretch: How much a glyph is allowed to be scaled horizontally in the positive direction for justification.
  • max_retract: How much extra negative spacing letters are allowed for justification.
  • max_expand: How much extra positive spacing letters are allowed for justification.

The main difference between this and tracking is that these are dynamically set per line for the purpose of more uniform justification.

@diquah diquah changed the title Add microjustification Add microtypography in paragraph justification Apr 11, 2025
@diquah
Copy link
Copy Markdown
Contributor Author

diquah commented Apr 11, 2025

Here is an updated version of the changes in this PR:

Added a new property, microtype to par. The microtype property includes two fields:

  • max_retract: Specifies the maximum distance a glyph is allowed to translate into its neighboring glyph during justification.
  • max_expand: Specifies the maximum distance a glyph is allowed to translate away from its neighboring glyph during justification.

Example

#table(
    stroke: none,
    gutter: 1em,
    columns: 3,
)[
    *Normal Justification*
][
    *Normal + Microtypographical justification*
][
    *Normal + Excessive Microtypographical justification*
][
    // Microtype properties are 0em (disabled) by default.
    #set par(justify: true)
    #lorem(100)
][
    // The "microtype" LaTeX package uses 2% as a sensible default. Here I've opted for 1.5%.
    #set par(justify: true, microtype: (max_expand: 0.015em, max_retract: 0.015em))
    #lorem(100)
][
    #set par(justify: true, microtype: (max_expand: 0.1em, max_retract: 0.1em))
    #lorem(100)
]
image

You can see that the text layout now takes advantage of the microtype features, instead of only applying after a line has been committed. Using microtype justification both shortens the vertical space of text and improves readability and uniformity of blocks. The degree of microtypographical justification is configurable by the user, and disabled by default.

This is especially useful for newspapers, magazines, papers, or books, which often have narrow width columns.

This PR does not include automatic glyph stretching for justification, but because the microtype property is a dictionary, adding additional microtype features in the future should not break backward-compatibility.

@Enivex
Copy link
Copy Markdown
Collaborator

Enivex commented Apr 11, 2025

That's a significant improvement!

@Enivex
Copy link
Copy Markdown
Collaborator

Enivex commented Apr 18, 2025

The "microtype" LaTeX package uses 2% as a sensible default

I should mention that microtype doesn't actually have this feature, apparently due to a limitation in pdftex. The 2% you are referring to is for font expansion specifically.

Microtype properties are 0em (disabled) by default

In my opinion they should eventually be on by default as long as the defaults are unintrusive. Perhaps not in an initial implementation and before we also have expansion and proper protrusion though. The combination of small amounts of each of these should almost universally be preferable to having them off.

Edit:
I should mention that 0.015em would be much more than 1.5%, since the spacing between characters is much less than 1em. You can tell from the picture that some lines are significantly more spread out (the one starting with "facete") or compressed (the one starting with "placeat")

I don't know what a sensible choice is, but less than this. It should also likely be relative to the distance between characters (including any additional tracking).

Copy link
Copy Markdown
Contributor

@tingerrr tingerrr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice and simple, great work!

@diquah
Copy link
Copy Markdown
Contributor Author

diquah commented May 3, 2025

I think I've made all the changes you've requested, mind giving this another review pass @tingerrr ?

Copy link
Copy Markdown
Contributor

@tingerrr tingerrr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, let's see what Laurenz says.

@iampritishpatil
Copy link
Copy Markdown

Just here to say, I've used LaTeX for years primarily because I like how microtypset text looks. Excited to see it come here!

@michaelfortunato
Copy link
Copy Markdown
Contributor

I'm excited for this!

@michaelfortunato
Copy link
Copy Markdown
Contributor

michaelfortunato commented May 13, 2025

Did a few experiments comparing this to how LaTex's article document class would render. For each experiment, on the left hand size is the LaTeX output, on the right is this branch for Typst.

Experiment 1

Settings

#set par(justify: true, microtype: (max-expand: 0.02em, max-retract: 0.015em))
image

Experiment 2

I prefer the results when I reduce the hyphenation cost

#set text(costs: (hyphenation: 30%))
#set par(justify: true, microtype: (max-expand: 0.02em, max-retract: 0.015em))
image

Latex Source

\documentclass[10pt, letterpaper]{article}
\usepackage[utf8]{inputenc} % allow utf-8 input
\usepackage[T1]{fontenc}    % use 8-bit T1 fonts, see https://tex.stackexchange.com/questions/664/why-should-i-use-usepackaget1fontenc
\begin{document}
% Title Section
\title{LipSum}
\author{Professor Typst}
\date{\today}
\maketitle
%
\section{Lorem Ipsum}
\lipsum{}
\end{document}

Typst Source

/// an attempt to replicate `\documentclass[10pt, letterpaper]{article}` 
/// TODO: I believe we can achieve this systematically by following https://www.latex-project.org/help/documentation/classes.pdf
/// however, Typst's uses a different definition of leading and its region layout is different than Tex's glue layout, so 
/// it might never be perfect, not to mention computer modern != new computer modern, the latter which being a bit thicker (I prefer it personally)  
#let latex-article(
  title: str,
  author: str,
  date: datetime.today().display("[month repr:long] [day], [year]"),
  eq-numbering: "(1)",
  doc,
) = {
  let title-page(title: "", author: "", date: none) = {
    set align(center)
    set block(spacing: 2em) // Space before title
    v(9.3em) // Space between title and author

    text(size: 16pt, weight: "thin")[#title]
    v(1.2em) // Space between title and author

    text(size: 12pt)[#author]
    v(.9em) // Space between author and date

    text(size: 12pt)[#date]
    v(1.2em) // Space after date before content
  }


  set text(size: 10pt, font: "New Computer Modern", lang: "en")
  show raw: set text(size: 10pt, font: "New Computer Modern Mono")
  // First, adjust your left margin to match LaTeX's actual calculation
  set page(
    paper: "us-letter",
    margin: (
      left: 47.25mm, // This is the exact LaTeX margin from the log
      right: 47.5mm, // Balanced margin
      top: 25.4mm,
      bottom: 38.1mm,
    ),
    numbering: "1",
  )
  // show heading.where(level: 1): set block(above: 1.4em, below: 1.15em)
  show heading: set block(above: 1.5em, below: 1.1em)
  // Then, update your heading style to match LaTeX's section indentation
  set heading(numbering: (..numbers) => {
    let number = numbers.pos().map(str).join(".")
    // No additional indentation before the number since we've set the correct margin
    [#number #h(0.8em)] // Just add the spacing between number and content
  })
  set par(
    leading: 0.55em,
    spacing: 0.55em,
    first-line-indent: 1.5em,
    justify: true,
  )
  title-page(title: title, date: date, author: author)
  set math.equation(numbering: eq-numbering)
  doc
}

@Enivex
Copy link
Copy Markdown
Collaborator

Enivex commented May 13, 2025

For a more apples-to-apples comparison you would need to use fontspec with New Computer Modern

@laurmaedje laurmaedje added layout Related to the layout category, which is about composing, positioning, etc. interface PRs that add to or change Typst's user-facing interface as opposed to internals or docs changes. labels Jun 4, 2025
@SolidTux
Copy link
Copy Markdown

SolidTux commented Jun 6, 2025

I'm very positively surprised how simple this looks! Is it correct that this currently only involves tracking, but not stretching of glyphs?

@Enivex
Copy link
Copy Markdown
Collaborator

Enivex commented Jun 6, 2025

I'm very positively surprised how simple this looks! Is it correct that this currently only involves tracking, but not stretching of glyphs?

It's only tracking, yes

@diquah
Copy link
Copy Markdown
Contributor Author

diquah commented Sep 28, 2025

Alright @tingerrr, This is everythings I planned to go into this PR. More enhancements to justification will come in later PRs by either me or some other enthusiast.

The changes are as follows:

  • A new justification-limits property is now available for par
  • By default it is set to none, which is interpreted as the default behavior for paragraph spacing before this PR was introduced. This makes this PR backwards compatible.
  • When set to auto, it uses sensible defaults, similar to Adobe InDesign or Affinity Publisher
  • It can also be set explicity supporting the full range of options as described in this comment.

Example:

#table(
    stroke: none,
    gutter: 1em,
    columns: 3,
)[
    *Default Justification (Pre-PR Behavior)*
    // Equivalent to Old Justification Rules, to show they are the same.
][
    *Justification With Auto Justification Limits*
][
    *Justification With Custom Justification Limits*
][
    #set par(justify: true)
    #set par(justify: true, justification-limits: none) // equivalent statement
    #lorem(100)
][
    #set par(justify: true, justification-limits: auto)
    #lorem(100)
][
    #set par(justify: true, justification-limits: (glyph: (min: 99% + 0pt, max: 100% + 0pt), word: (min: 80% + 0pt, max: 133% + 0pt)))
    #lorem(100)
]
image

P.S.
Thank you for the kind words @sicikh, as I have less and less time to work on personal projects I'm hoping to hammer this PR out with the available features and hopefully it can be expanded later on to support ligature breaking, font stretching, and all that goodness.

Copy link
Copy Markdown
Contributor

@sicikh sicikh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should need to add finish checks to word and glyph subdictionaries.

diquah and others added 2 commits September 28, 2025 11:54
Co-authored-by: Kirill Lukashev <kirill.lukashev.sic@gmail.com>
@diquah diquah requested review from sicikh and tingerrr September 28, 2025 18:59
Copy link
Copy Markdown
Contributor

@sicikh sicikh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just minor formatting fix

let mut word: Dict = dict.take("word")?.cast()?;
let word_min = word.take("min")?.cast()?;
let word_max = word.take("max")?.cast()?;
word.finish(&["min", "max"])?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
word.finish(&["min", "max"])?;
word.finish(&["min", "max"])?;

oopsie, forgot to include leading spaces. How did it pass formatting checks though 🤔 same thing below

let mut glyph: Dict = dict.take("glyph")?.cast()?;
let glyph_min = glyph.take("min")?.cast()?;
let glyph_max = glyph.take("max")?.cast()?;
glyph.finish(&["min", "max"])?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
glyph.finish(&["min", "max"])?;
glyph.finish(&["min", "max"])?;

same thing as above with word.finish

Copy link
Copy Markdown
Member

@laurmaedje laurmaedje left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One comment on which I'd appreciate feedback. The PR also needs tests and documentation, but to make this possible in time for the release, we'd take care of that internally.

Comment on lines +241 to +250
stretchability: (
Em::zero(),
(width / 2.0) * justification_limits.word_max.rel.get()
+ Em::from_length(justification_limits.word_max.abs, font_size),
),
shrinkability: (
Em::zero(),
(width / 3.0) * justification_limits.word_min.rel.get()
+ Em::from_length(justification_limits.word_min.abs, font_size),
),
Copy link
Copy Markdown
Member

@laurmaedje laurmaedje Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems odd to me that the calculation works differently for the space. Wouldn't it make more sense for the default value of justification-limits to be adjusted instead? Basically making this the default value:

#(
  word: (
    min: 100% * (2 / 3),
    max: 150%,
  ),
  glyph: (
    min: 100%,
    max: 100%,
  )
)

And then we'd remove all of the none and auto things and just add suggested values for glyph to the documentation.

I'd also (as suggested by someone on Discord) add support for folding such that #set par(justification-limits: (glyph: (min: 98%, max: 102%)) does not override the existing values for word and #set par(justification-limits: (glyph: (min: 95%)) not the existing value for glyph.max. Dealing with folding is always a bit tricky though, so I could handle that myself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My train of thought was that the justification-limits are relative to the internal layout algorithm. From a UX perspective, I feel that users should not need to know any magic numbers in order to modify the layout algorithm.

I imagine the most common mental model for this feature would be that 100% means 100% of the spacing determined by the internal layout engine. 200% min word limit would mean that the spaces should be able to compress to double what they're at before modifying this value.

Otherwise, as you suggested, we'd just have the default values written in the documentation.

However, the flip side of this coin is that if 200% meant 200% of the engine's default spacing rule, if someone wanted to explicitly say 200% of an actual non-justified space, they'd have to know about the internal 2/3 and then write the true 100% as 3/2 multiplied by their initially desired value.

This could also be written into the documentation, but I just think that the first case that I mentioned would happen more often than the second? But the second one is more confusing to the end user if that is their desired behavior.

It's up to you, I just wanted to outline my thinking.

@ghost
Copy link
Copy Markdown

ghost commented Oct 2, 2025

I think it would be good to have checks for max >= min.

@laurmaedje
Copy link
Copy Markdown
Member

laurmaedje commented Oct 3, 2025

Just as a heads-up: We're working on this internally now, so please don't push further commits. There are some layout issues that still need figuring out, e.g. end-of-line glyphs being incorrectly adjusted.

@laurmaedje
Copy link
Copy Markdown
Member

I have now finalized the PR. There are some crucial changes to the implementation and the API:

  • Stretchability of glyphs is not relative to the glyph width anymore. For spaces, it makes sense to say "you can stretch this down to 70% of the size", but for glyphs it is problematic because glyphs have varying widths so the stretch would also vary, leading to uneven spacing. In the new design, the extra gap that can be inserted between glyphs is the same between each pair of glyphs. This is in line with how the CSS text-justify: inter-character; property behaves.

  • Instead of word and glyph, the dictionary now takes the keys spacing and tracking. These mirror exactly the text.spacing and text.tracking properties and their types have been changed accordingly. The behavior of the metrics is adjusted to be consistent with spacing and tracking. For spacing (as it is relative), min and max define the absolute minimum and maximum width to which spaces may be adjusted. For tracking (as it is additive), min and max define the amount by which they may be adjusted. Notably, the non-relative part of min must always be nonpositive, while the non-relative part of max must always be nonnegative (this is checked). Moreover, for spaces, the tracking value acts in addition to the spacing value (just like text.tracking does in addition to text.spacing).

  • Glyphs at the end of the line are not adjusted anymore. This was a bit hard to spot under normal circumstances, but excessive character-level justification would reveal uneven line endings.

  • For consecutive glyphs belonging to the same shaping cluster, only the last one receives stretchability. This can avoid issues with complex scripts, accents, and such.

  • The property is now foldable, meaning that tracking and spacing can be individually specified and the other will keep its previous value. The min and max values, in contrast, must always be both specified.

  • Excessive shrinking (below 25% of the glyph width) is clamped as zero-sized or negative glyph widths cause paragraph layout to take very long or not terminate at all.

  • Added extensive documentation with full clarification on how the values behave.

  • Added tests for API and layout

@SolidTux
Copy link
Copy Markdown

SolidTux commented Oct 7, 2025

That sounds great!

Excessive shrinking (below 25% of the glyph width) is clamped as zero-sized or negative glyph widths cause paragraph layout to take very long or not terminate at all.

Is there a reason why this is clamped instead of throwing an error? I'd rather have the compilation fail than my input being silently treated differently when I specify and invalid value.

@laurmaedje
Copy link
Copy Markdown
Member

Is there a reason why this is clamped instead of throwing an error? I'd rather have the compilation fail than my input being silently treated differently when I specify and invalid value.

I can't check this fully at the site where you define the value as it is font size and glyph size dependant, so the error would be very non-local and it would fail at some random piece of text.

@laurmaedje laurmaedje changed the title Support for glyph-level justification Support for character-level justification Oct 7, 2025
@ghost
Copy link
Copy Markdown

ghost commented Oct 8, 2025

I compiled the new version, I think it works good and the spacing is a little bit simpler to adjust than before (min: 30 % starts to make it work). Smaller values than 25 % had no effect to spacing, not sure if these values were limited too or if it just did converge.

  • The following error should get improved, at least when both parameters spacing and tracking are used.

    error: min value is invalid (expected length, found float)

    Instead, maybe use:

    error: min value of tracking is invalid (expected length, found float)

  • My previous comment is fixed now by the positive/negative value checks.

  • The desired value as discussed before, is missing and not automatically targeted to the default value. This means that the tracking is often still applied from the user defined range even though it could be the default (connected fonts will become slightly unconnected even though the words per line would not change). This was the same before, and is probably difficult to change.

To sum up: everything works good, but the error should get improved as shown above.

@laurmaedje
Copy link
Copy Markdown
Member

The following error should get improved, at least when both parameters spacing and tracking are used.

Yeah, I noticed this, but didn't really feel like fixing it for this PR specifically because it is a problem across the whole of Typst with such kinds of dictionaries and should really be fixed holistically. However, it's true that it's especially bad in this case, so I implemented a workaround now.

@laurmaedje laurmaedje enabled auto-merge October 8, 2025 08:34
@laurmaedje laurmaedje added this pull request to the merge queue Oct 8, 2025
Merged via the queue into typst:main with commit 398866c Oct 8, 2025
8 checks passed
@laurmaedje
Copy link
Copy Markdown
Member

Thanks @diquah and also everyone else who helped move this forward!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

interface PRs that add to or change Typst's user-facing interface as opposed to internals or docs changes. layout Related to the layout category, which is about composing, positioning, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.