Skip to content

fix typst color regex bug and add typst-css unit tests#14469

Merged
gordonwoodhull merged 4 commits into
mainfrom
bugfix/14460
May 1, 2026
Merged

fix typst color regex bug and add typst-css unit tests#14469
gordonwoodhull merged 4 commits into
mainfrom
bugfix/14460

Conversation

@gordonwoodhull
Copy link
Copy Markdown
Member

Summary

  • One-character fix for Typst CSS misinterprets 0px border when color is specified as rgb() #14460: anchor the %w+%b() color matcher in translate_border and consume_color so width/style tokens that precede an rgb()/rgba() color aren't skipped (e.g. border: 0px solid rgb(255,0,0) now correctly suppresses the border instead of rendering at the default 2.25pt).
  • While writing the regression test, audited the rest of src/resources/filters/modules/typst_css.lua and fixed ten more latent bugs found there. Highlights:
    • var(--brand-NAME) references crashed the Typst filter when NAME contained digits (e.g. --brand-red-50).
    • parse_opacity('not-a-number') crashed with math.min(1.0, nil).
    • dvmin length unit silently rejected (stray trailing space in the unit table).
    • CSS keywords like BOLD/Normal not recognized as bold/normal (CSS keywords are case-insensitive, but the module's lookups were case-sensitive).
    • Invalid hex colors like #fffff silently parsed as 2-component colors.
    • Several missing locals leaking globals (zero, fbeg, fend, parse_multiple) on every module load.
    • set_brand_mode was a phantom export — set_brand_mode = set_brand_mode in the export table but never defined anywhere; never had a caller.
  • Adds a small luaunit-based unit-test convention:
    • tests/unit-lua/<feature>.test.lua — luaunit suites that end in os.exit(lu.LuaUnit.run()).
    • tests/smoke/lua-unit/lua-unit.test.ts — Deno smoke test that runs each listed Lua suite via quarto run with LUA_PATH set, surfacing luaunit failures through the Deno test runner.
    • First pass uses it for typst_css.lua; the same shape works for any other Lua module that wants direct unit coverage.
  • 47 unit tests covering every public function in typst_css.lua, including regression tests for all bugs fixed here. luacheck on the module is now clean (was 9 warnings).

Test plan

  • cd tests && ./run-fast-tests.sh smoke/lua-unit/lua-unit.test.ts → 1 Deno test passes (47 luaunit subtests, all green)
  • Render the repro from Typst CSS misinterprets 0px border when color is specified as rgb() #14460 (named/hex/rgb/rgba zero-px borders) to Typst → all four cells render bare, matching the issue's expected output
  • Spot-render an existing Typst document that uses brand colors and/or CSS borders to confirm no behavioral regression

CSS `border` and `border-color` declarations lost any tokens that
preceded an `rgb()` / `rgba()` color because `s:find('%w+%b()', start)`
in `translate_border` and `consume_color` is unanchored — it would skip
past width/style (or earlier color) tokens to match the parens-bearing
form. With `border: 0px solid rgb(255, 0, 0)` the width and style were
never seen, leaving the default `medium` (2.25pt) thickness.

Adding `^` anchors the search at `start` so the leading token is
consumed first, matching how named/hex colors already worked.

Also adds a luaunit-based unit-test convention:
- tests/unit-lua/*.test.lua  (luaunit suites; ends in os.exit(lu...))
- tests/smoke/lua-unit/lua-unit.test.ts runs each via `quarto run` with
  LUA_PATH set so `require('luaunit')` and filter-module requires work.

Fixes #14460
While writing tests for #14460, found and fixed five latent bugs in
src/resources/filters/modules/typst_css.lua:

- parse_color: brand-color reference path (var(--brand-NAME)) crashed
  when NAME contained digits. The capture class [%a--]* excluded %d, so
  e.g. var(--brand-red-50) failed to match and fell into a broken error
  branch that concatenated an undefined `v` and `return null`. Widened
  the class to [%w-] and fixed the error branch to use the actual color
  string and return nil.

- output_color: implicit global `zero` (missing `local`) leaked from the
  no-color-with-opacity path. Made it local.

- translate_font_weight: passed `null` (an undefined global -> nil) to
  output_warning, silently bypassing any user-supplied warnings
  collector. Changed to forward `warnings`.

- consume_width / consume_style: `fbeg, fend = ...` without `local`
  leaked them as globals on every call. Added `local`.

Each fix is covered by a regression test in tests/unit-lua/typst-css.test.lua.
- parse_multiple was declared without `local`, leaking as a global on
  every module load. Made it `local function` (forward use in
  translate_border still works since the local is declared above).
- Removed `set_brand_mode = set_brand_mode,` from the export table:
  set_brand_mode is never defined in this module and has no callers
  anywhere, so the export silently resolved to nil — a phantom API
  surface.
- Removed dead `local mult = 1` initial assignment (immediately
  overwritten in every branch); now just `local mult` then assigned.
- Removed unused `local dash` in translate_border_style.
- Removed a duplicate `local function quote(s)` definition (two
  identical copies of the same function body, the first one dead).
- Removed a trailing-whitespace nit.

Two regression tests added in tests/unit-lua/typst-css.test.lua:
- TestModuleNoGlobalLeak.testParseMultipleNotGlobal — `_G.parse_multiple`
  must be nil after the module loads.
- TestModuleExports.testNoPhantomExports — every key listed on the LHS
  of the module's `return { ... }` block must resolve to a non-nil
  value on the loaded module. (pairs() can't see this because Lua
  tables drop nil-valued keys, so the test reads the source file.)
While locking in coverage for every public function in
src/resources/filters/modules/typst_css.lua, found and fixed five more
latent bugs:

- parse_opacity: tonumber() can return nil (e.g. for `rgba(0 0 0 / abc)`),
  but the value was passed directly to math.min(1.0, ...) which raises
  "number expected, got nil" and crashes the filter. Now returns nil
  when the operand isn't a number, on both the percent and fraction
  branches.

- parse_color: hex colors of length 1, 2, 5, or 7 (and any with non-hex
  characters) were silently accepted as 1-, 2-, 2-, or 3-component rgb
  values because gmatch '..' just dropped trailing odd digits and there
  was no character validation. Now requires exactly 3, 4, 6, or 8 hex
  digits and emits an "invalid hex color" warning otherwise.

- length_units: 'dvmin ' (with a stray trailing space) caused
  parse_length_unit('10dvmin') to fall through to the shorter 'vmin',
  silently rejecting any `dvmin` length. Removed the space.

- translate_font_weight: CSS keywords are case-insensitive, but the
  module's lookup tables only had lowercase entries, so values like
  'BOLD' or 'Normal' fell into the "invalid font weight" branch. Now
  lowercases the input before lookup.

- translate_font_family_list: a whitespace-only input like '   ' was
  emitted as `("",)` because the gmatch matched the whitespace run as
  one token, leading-space trim made it empty, but it was still inserted
  as a quoted empty family. Now also strips trailing whitespace and
  skips empty results.

Twenty-five new unit tests (5 failing-regression + 20 lock-in) added to
tests/unit-lua/typst-css.test.lua, covering parse_opacity, parse_color
(hex paths), parse_color-via-rgb, output_color, parse_length_unit (the
dvmin case), translate_border (default propagation + ordering),
translate_font_weight, translate_font_family_list, and
expand_side_shorthand. Total Lua unit tests: 47, all passing.
@posit-snyk-bot
Copy link
Copy Markdown
Collaborator

posit-snyk-bot commented May 1, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@gordonwoodhull gordonwoodhull merged commit 6ade5e0 into main May 1, 2026
92 of 93 checks passed
@gordonwoodhull gordonwoodhull deleted the bugfix/14460 branch May 1, 2026 19:11
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.

2 participants