Skip to content

css: consolidate @font-face parsing - #713

Open
spfenwick wants to merge 1 commit into
koreader:masterfrom
spfenwick:font-face-parser
Open

css: consolidate @font-face parsing#713
spfenwick wants to merge 1 commit into
koreader:masterfrom
spfenwick:font-face-parser

Conversation

@spfenwick

@spfenwick spfenwick commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Comments

@poire-z - I'm interested in your thoughts on this change and whether you think it is warranted.

This follows up on the @font-face parsing that we discussed briefly during the work on the font manager. I decided to see what it would look like if @font-face is moved into the main CSS parser and this is the outcome.

The changes are pretty contained and I find easier to follow as this avoids special cases like reparsing on embedded @font-face:

  • The state machine parser lifts cleanly out of epubfmt (marked with #if 0's)
  • The new parser in lvstsheet (obviously). I asked Claude to follow the conventions of the rest of the parser, so hopefully that's happened and the new code is more maintainable.
  • A single new function in lvtinydom to register font faces one by one rather than in bulk
  • Minor changes to lvfntman due to the parser now handling numeric weights rather than a normal/bold flag.

I've spent quite a bit of time looking for any areas where parsing the CSS in a single phase might cause problems - captured in the the fontface test epub and the test plan. I know of a single edge case that occurs in malformed epubs where a @font-face is used in an early DocFragment but only declared in a later one (test case 4 in the epub). In that situation the font isn't applied on the first pass through the epub but is if the document is closed and then reopened - admittedly odd behaviour but should never occur in practice (it only happens with malformed epubs) and only affects the font - all other rendering is correct.

Rationale

@font-face was previously parsed by a separate, one-off scanner that only looked at the EPUB's OPF-listed spine content, before the main CSS stylesheet parser ever ran. That split was the root cause of several independent bugs: styletweak-declared fonts were invisible to it entirely, numeric font-weight values weren't part of its parsing, and its batch pre/post-scan design needed a forceReinitStyles() workaround to paper over registration-ordering gaps. Parsing @font-face as part of normal CSS parsing — the same path every other rule already goes through — fixes that whole bug class at once instead of patching each symptom separately.

Summary

  • Move @font-face parsing out of the standalone EPUB pre-scan (EmbeddedFontStyleParser in epubfmt.cpp) and into the normal CSS stylesheet parser (parse_font_face_rule() in lvstsheet.cpp), so @font-face rules are picked up by every CSS source that goes through stylesheet parsing: external stylesheets, <head><style> blocks, and KOReader styletweaks.
  • Fonts are now registered incrementally, per DocFragment, as their declaring rule is parsed — rather than in a single batch pass over the whole EPUB after all fragments are read. This is what lets an embedded font take effect before the DocFragment that needs it is styled.
  • Replace LVEmbeddedFontDef's _bold boolean with a _weight integer, so the font-weight descriptor can carry any parsed numeric value, not just bold/normal.

Bugs fixed

  • koreader#10040 / koreader#12525 — numeric font-weight on @font-face (e.g. font-weight: 900) was silently dropped and the face registered at 400, so requesting that weight synthesised it from the wrong base face instead of using the actually-declared one.
  • koreader#10604@font-face declared in a KOReader styletweak was silently ignored, since the old parser only scanned the EPUB's own OPF-listed content, not tweak CSS merged in afterward. Styletweaks now register fonts the same way as any other stylesheet source, from the first page.

Other notes

  • Font-list cache serialization format changed (_bold_weight); cache magic bumped so stale cache entries are discarded and re-rendered once rather than misread.
  • EmbeddedFontStyleParser and the EPUB pre/post-scan + forceReinitStyles() cycle it drove are removed as dead code, no longer needed now that registration happens inline during stylesheet parsing.
  • Added tests/FONTFACE_TEST_PLAN.md, tests/make_fontface_test_epub.py (generates tests/fontface-test.epub), and tests/font_face_demo.css — manual/visual regression coverage for registration ordering, the numeric-weight fix, and the styletweak fix.

Known follow-up (not fixed here)

  • A pre-existing, unrelated font-resolution cache can serve a stale font for a DocFragment styled before its @font-face rule was registered, if two DocFragments end up with byte-identical computed styles. Worth its own investigation.
  • This change only covers the EPUB path; a similar registration-ordering gap exists in the CHM DocFragment path, tracked separately.

Test plan

  • See tests/FONTFACE_TEST_PLAN.md for the manual/visual verification procedure.

This change is Reviewable

@poire-z

poire-z commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Will review it.
Just mentionning a few other issues related to font-face:
#516
koreader/koreader#10040
koreader/koreader#14287
koreader/koreader#15557
I remember there were 2 issues, one about italic, one about bold, where we maybe wrongly derive the font to use when font-style or font-weight were not set for all 4 combinations, or something around that, may be it's in these issues. I don't remember if somehow these were fixed by your initial fontmanager refactor, or if by chance this PR may fix them.

@benoit-pierre

Copy link
Copy Markdown
Member

What is it with this #if 0 code? Are we going to keep dead code like that?

Comment thread crengine/src/lvfntman.cpp
Comment on lines +336 to +339
// Bumped: LVEmbeddedFontDef gained _weight (replacing _bold),
// changing the serialise layout. Stale cache entries are discarded
// and re-rendered once.
static const char * EMBEDDED_FONT_DEF_MAGIC = "FNTD2";

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.

No need for this I think (we never bumped it before), we will bump as we usually do:

/// change in case of incompatible changes in swap/cache file format to avoid using incompatible swap file
// increment to force complete reload/reparsing of old file
#define CACHE_FILE_FORMAT_VERSION "3.05.80k"

// Drop the whole map so any such stale resolution is redone against
// the now-updated font registry -- see FONTFACE_PARSER_REFACTOR.md,
// "Bug found via runtime verification: stale font-resolution cache".
_fontMap.clear();

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.

Is this really ok to do ? We have created styles in the previous part of the DOM, associated a font. if we clear this map, won't these past associations be lost, or have old index resolved to not the same font as before the clear()?
(Just asking, no longer really clear to me how this works.)

Comment on lines +2729 to +2734
if ( name == css_at_font_face && parseFontFace ) {
str++; // skip opening '{'
parse_font_face_rule( str, doc, codeBase ); // consumes up to and including the closing '}'
skip_spaces(str);
return true;
}

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.

I understand (took me some time, so maybe worth a comment) when !parseFontFace, the content will be properly skipped below because has_nested_declaration is set to true for @font-face), right ? (Just as it was previously, but I wondered ohw it bahave when !parseFontFace).

@poire-z

poire-z commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The parsing looks sound and is readable.

I'm a bit concerned about the "between fresh parse and cache reopen" stuff (haven't fully followed the explanations in the .md), which I think is (just?) about what you wrote in the top post:

I know of a single edge case that occurs in malformed epubs where a @font-face is used in an early DocFragment but only declared in a later one (test case 4 in the epub). In that situation the font isn't applied on the first pass through the epub but is if the document is closed and then reopened - admittedly odd behaviour but should never occur in practice (it only happens with malformed epubs) and only affects the font - all other rendering is correct.

Would this situation be noticed by some hash change ? - in which case a re-render will happen and things may end well (but I would like to see a printf("CRE: document loaded, but styles re-init needed (cause: xyz) so we can know why it happened instead of assuming it's a bug that I will want to investigate).
If not, then we get the risk of overlapping paragraphs (ie. if initially rendered with the wrong tont and getting measured 10 lines - and when rendered with the new font and it being larger we get 11 lines, the next paragraph will start being displayed over that 11th line).
(Even if such malformed EPUBS should never occur in practice, I'd like to avoid that.)

Related concerns (or probably I'm just reformulating the previous concern :)):
What happens if @font-face ( font-family: "MyFont"; src("font1.ttf") } in one DocFragment, and @font-face ( font-family: "MyFont"; src("font2.ttf") } in a later fragment ?
Which of font1.ttf or font2.ttf will be used in the 1st fragment and in the 2nd fragment, 1) when measuring (first render, to get paragraph heights) and 2) which will be used for drawing when we display a page of the first fragement, and one of the later fragment?

Dunno how much complicated/ugly it would be to register such fonts (or aliases), additionnally to per-document (our documentId), per this-document fragment number ? (and how much memory wasted to register the same font-file under the same name for 500 fragments).

where a @font-face is used in an early DocFragment but only declared in a later one

But in this case, when the first fragment was parsed it didn't find the font, so it uses the default font, which sounds fine: that default font is associated to the style (or maybe not? it's just its family name, so the style will get the same hash as a node from a later fragment and will be reused ?) - and all is at it should be, no problem, even when we find that font-family name when parsing a later DocFragment, it's too late, it won't affect it, and it should not, no ? (or maybe not? we'll the use the default font that was associated to this style shared with the node from the first fragment, and we won't use the one from that font-face now defined?).
When re-rendering, we will reparase all the stylesheets (I think), and we will re-meet all the @font-face in the same order we did on the opening. Do (or should we if we don't) clear document registered fonts in this case, so this happens?

What is it with this #if 0 code? Are we going to keep dead code like that?

Yes, we can/will remove that code. I insisted on getting these #if 0 during lvfntman refactor because it was making the diff hard to read otherwise with just removal. @spfenwick was nice enough to go on with that idea in this PR :), but for this epubfmt.cpp where it's a only a few small sections, we can just delete them (it won't make it harder to read, quite the contrary, no need to expand lines to see what is becoming removed or dead code :)).

@spfenwick

Copy link
Copy Markdown
Contributor Author

I think the most correct way to deal with the "between fresh parse and cache reopen" issue would be to scope the @font-face to the fragment.

This would be a change from the old implementation where @font-face declarations are scoped to the document and shared across all fragments - even if the declaration is in a css that's in the spine but not referenced from any xhtml at all then it's currently picked up in the pre-scan.

If we scope the declaration to the fragment, then fragment A would never see a @font-face that's not declared until a later fragment B, either on initial parse or reloading the document. The behaviour would be different from current but consistent - and probably more correct.

Based on previous discussions with Claude it looks like that change would be feasible. The cost shouldn't be high as similar mechanisms are already in place to scope normal CSS rules, including caching to avoid duplicating rules between fragments. However it looked like it would mean touching the code in quite a few places to pass the fragment ID to everywhere it's needed so I didn't proceed with it at the time.

I'll have a go at implementing that scoping change - probably later today.

@poire-z

poire-z commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Yes, feels like the most proper solution. Let's see how kludgy it would be.
(Should probably be also accounted in some existing hash, not sure which and how.)

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.

3 participants