Skip to content

Reject ambiguous mappings in PiecewiseLinearMap::new - #2083

Open
hdimer wants to merge 2 commits into
googlefonts:mainfrom
hdimer:plm-reject-duplicate-from
Open

Reject ambiguous mappings in PiecewiseLinearMap::new#2083
hdimer wants to merge 2 commits into
googlefonts:mainfrom
hdimer:plm-reject-duplicate-from

Conversation

@hdimer

@hdimer hdimer commented Aug 18, 2026

Copy link
Copy Markdown

Fixes #937.

The bug

PiecewiseLinearMap::new sorted the (from, to) pairs and unzipped them into parallel Vecs with no duplicate detection and no error path. Two mappings sharing a from but disagreeing on to were both stored, and map() then resolved an exact hit via partition_point and returned whichever sorted first, silently dropping the other mapping.

On main (ee249ef):

let plm = PiecewiseLinearMap::new(vec![(0.0, 0.0), (10.0, 100.0), (10.0, 999.0)]);
// len() == 3, map(10.0) == 100.0 -- (10.0, 999.0) is silently discarded

The fix

new now returns Result, with a DuplicateMapInput variant on the existing fontdrasil::error::Error. CoordConverter::new was already fallible, so both call sites just propagate with ? and nothing outside fontdrasil changes. (PiecewiseLinearMap is crate-private, so this isn't a public API break; Error does gain a variant.)

Two judgement calls worth your attention:

Duplicate from is rejected, duplicate to is not. Per @anthrotype in #1528, "duplicate keys are bad, but duplicate values are fine". reverse() therefore builds its map without the check, since reversing a many-to-one map is exactly how duplicate from values legitimately arise, and map() resolves those to the first match on purpose (the ufo2ft#978 behaviour).

Exact duplicate mappings are collapsed rather than rejected. Repeating a mapping identically is redundant, not ambiguous, and nothing gets discarded. This is not hypothetical: fea-rs's simple_axis builds [(min, min), (default, default), (max, max)], so any axis with min == default repeats a mapping, and rejecting those broke the fea-rs suite. CoordConverter::unmapped already handles the same case with mappings.dedup(), so this follows the idiom that was already in the file. Happy to make it strict instead if you'd rather callers pre-dedup.

Things to weigh

  • This makes fontc stricter than fontmake. fonttools builds its mapping from a dict, so a conflicting duplicate <map> collapses last-wins and never errors; fontc previously took first-after-sort. A source in the wild with a genuinely conflicting duplicate will now fail a build that used to succeed (quietly disagreeing with fontmake). That's what let PiecewiseLinearMap::new return error if input mappings contain duplicate keys #937 asks for, but a crater run is the real gate here, not the unit tests. Reachable from .designspace and fontra; the glyphs path keys axis_mappings by user value, so duplicates can't survive parsing.
  • avar gets slightly more spec-correct. to_segment_map is the only production consumer of CoordConverter::iter(). For a source with an exactly-repeated <map>, it previously emitted a SegmentMaps with a duplicate fromCoordinate, which the spec forbids. fvar is unaffected.
  • Out of scope, but noted: CoordConverter::default_idx indexes the caller's mappings, not user_to_design, which has always been sorted and is now also deduped. Nothing in production reads the field, so I only documented it rather than recomputing it. Say the word if you'd like it fixed properly, here or separately.

Testing

cargo fmt, cargo clippy --all-features --all-targets -D warnings, and cargo doc -D warnings are clean, and the full suite passes: fontdrasil 63, fontir 180, glyphs-reader 278, glyphs2fontir 89, ufo2fontir 80, fontra2fontir 20, fontbe 132, fontc 202. I couldn't run the fea-rs ttx tests or fontc_crater locally (no ttx on PATH, no cmake for tidy-sys); both fail identically on a clean checkout here, and fontc_crater has no fontdrasil dependency.

I mutation-tested the new tests rather than trusting them: dropping the dedup(), deduping by from alone (which would reintroduce the original silent drop), and comparing to instead of from are each caught by at least two tests.

I use AI tooling to help with my open source work. This patch was written and tested with that assistance, and I've reviewed and verified all of it myself.

new() sorted the (from, to) pairs into parallel Vecs without checking for
duplicate 'from' values. When two mappings shared a 'from' but disagreed on
'to', both were stored and map() returned whichever sorted first, silently
discarding the other mapping.

new() now returns a Result. Exact duplicate mappings are collapsed, since
repeating a mapping is redundant rather than ambiguous, and mappings that
share a 'from' but disagree on 'to' are rejected. Duplicate 'to' values stay
legal, so reverse() builds its map without the check: reversing a many-to-one
map is exactly how duplicate 'from' values legitimately arise, and map()
resolves those to the first match on purpose.

CoordConverter::new was already fallible, so both call sites just propagate.

Fixes googlefonts#937
@google-cla

google-cla Bot commented Aug 18, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Comment thread fontdrasil/src/coords.rs Outdated

@anthrotype anthrotype left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The core approach looks right: exact duplicates are harmlessly collapsed, conflicting input coordinates become a well-scoped error, and reverse() preserves the intentional many-to-one semantics from #1528. Apart from the default_idx cleanup, I have no code concerns.

@hdimer
hdimer marked this pull request as ready for review August 18, 2026 13:34
It indexed the caller's mappings, which user_to_design no longer
matches once sorted and deduped, and nothing read it back. Keep it as
a constructor parameter to pick design_default.

unmapped_coords_get_deduped asserted nothing else, so it now checks the
dedup count and that the default still normalizes to 0.

@anthrotype anthrotype left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks. mind also signing the CLA?

@hdimer

hdimer commented Aug 18, 2026

Copy link
Copy Markdown
Author

Thanks. mind also signing the CLA?

All done. Let me know if you need anything else.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

let PiecewiseLinearMap::new return error if input mappings contain duplicate keys

2 participants