Skip to content

CSS: add support for pseudo element ::first-letter#648

Merged
poire-z merged 3 commits into
koreader:masterfrom
poire-z:first_letter
Feb 1, 2026
Merged

CSS: add support for pseudo element ::first-letter#648
poire-z merged 3 commits into
koreader:masterfrom
poire-z:first_letter

Conversation

@poire-z

@poire-z poire-z commented Jan 31, 2026

Copy link
Copy Markdown
Contributor

fb2def.h: add a few common attributes

Having them known and a fixed id can avoid stylesheet hash mismatches.

lvtext: includes src->t.offset in word->t.start

AddSourceLine() (used by lvrend to add text fragment) can be provided with a offset value indicating that some leading chars of the source text node should be skipped.
No code was ever providing an offset different than 0, so it didn't matter if things were a bit inconsistent.

We will use that offset to support CSS ::first-letter (in next commit) so make things consistent, with two simple facts:

  • really only add text starting at offset to m_text
  • word->t.start counts from the source text node, and not from the offset (which makes things simpler elsewhere, including when dealing with xpointers)

CSS: support for pseudo element ::first-letter

Handled as another variation of the pseudoElem internal element we introduced to support ::before and ::after, so we benefit from its existing CSS parsing/checking/applying and styles inheritance.

Have it inserted just before the text node it should pick its first letter from, store in a FirstLetter= attribute how many chars should be picked from the next text node and rendered when we render that pseudoElem, and skipped when rendering the text node itself.

Known non-conformance to the CSS specs:

  • we don't limit which CSS properties can be used (the specs enumarate a subset of known properties).
  • we may apply it where we should not (ie. among inlines, propagate it from paragraph to a first inline-table...) (Firefox and Chrome already have different behaviours in this regard).
  • if there are a ::before and a ::first-letter, we won't pick the first letter from that ::before's content, and we will render the ::before BEFORE the first-letter picked from the following text.

First letters can be disabled with using
::first-leter { display: none; }
(display: being one of the property the specs say we should not support, we can use it to disable them with style tweaks.)

Should allow closing:
koreader/koreader#6944
koreader/koreader#9142
koreader/koreader#14391

Discussed in #646.
Done with the help of Github copilot by guiding it step by step along the idea I had in mind. Experience related in #646 (comment).
Full Copilot conversation/copiloting in poire-z#2 (140 conversation posts, 48 commits!...)


This change is Reviewable

Having them known and a fixed id can avoid
stylesheet hash mismatches.
AddSourceLine() (used by lvrend to add text fragment) can
be provided with a 'offset' value indicating that some
leading chars of the source text node should be skipped.
No code was ever providing an offset different than 0,
so it didn't matter if things were a bit inconsistent.

We will use that offset to support CSS '::first-letter'
(in next commit) so make things consistent, with two
simple facts:
- really only add text starting at offset to m_text
- word->t.start counts from the source text node, and
  not from the offset (which makes things simpler
  elsewhere, including when dealing with xpointers)
@poire-z

poire-z commented Jan 31, 2026

Copy link
Copy Markdown
Contributor Author

Keeping here the full description Copilot has updated in the top post of poire-z#2, for reference (some bits are not fully true or right).


Adds complete infrastructure for ::first-letter pseudo-element following the established ::after pattern. Creates pseudo elements with proper CSS selector matching and implements text rendering infrastructure.

Changes

CSS Parsing and Style Matching (lvstsheet.cpp)

  • Extended applyToPseudoElement() to handle csspe_first_letter cases
  • Simplified LVCssSelector::check() for FirstLetter by finding ancestor with HasFirstLetter attribute and delegating to standard checking code
  • Fixed LVStyleSheet::apply() to properly identify FirstLetter pseudo elements

DOM Element Creation (lvtinydom.cpp)

  • Added attr_HasFirstLetter attribute to mark elements requiring first-letter handling
  • Implemented ensureFirstLetter() method to consolidate FirstLetter creation logic
    • Handles both initial DOM building and runtime stylesheet changes
    • Inserts FirstLetter at textNodeIndex position (text shifts forward)
    • Supports text nodes at any index (not limited to specific positions)
    • Works with elements like <p><b> </b><em> </em>Text where text is at index 2+
  • Supports ::before and ::first-letter together with consistent ordering: ::before::first-letter → text
  • Added initStyle parameter to handle different contexts (DOM building vs stylesheet re-application)
  • Modified ldomElementWriter::onBodyExit() to create FirstLetter pseudo-elements
  • Fixed isBoxingNode() to respect exceptBoxingNodeId for pseudoElems
  • Fixed createXPointer() to avoid double-counting offsets
  • Fixed ldomXPointer::getRect() to handle FirstLetter pseudo-elements
  • Added helper methods getFirstLetterPseudoElem() and getFirstLetterTextNode() for code reuse
    • Only checks previous sibling for FirstLetter detection (optimized)
    • Works at any text node index
    • Handles boxing nodes and display:none checking
    • Reduces code duplication across multiple call sites

Text Formatting Infrastructure (lvtextfm.cpp)

  • Changed word->t.start to be absolute positions (including offset)
  • Updated buffer calculations and text copying to account for offsets
  • Fixed link detection and character replacement to use absolute positions

DOM Traversal and Text Rendering (lvrend.cpp)

  • Implemented FirstLetter handling in renderFinalBlock() and getRenderedWidths() using helper methods
  • Optimized setNodeStyle() to call ensureFirstLetter() before ensurePseudoElement(true) for early return optimization
  • Fixed getRenderedWidths() to handle ::before/::after text length
  • Factorized duplicated code using helper methods

Implementation Details

ensureFirstLetter() Method:

  • Inserts FirstLetter at position textNodeIndex (text node shifts forward to textNodeIndex + 1)
  • Works correctly when textNodeIndex == 0 (no pseudo-elements) or > 0 (after ::before or other elements)
  • Natural pseudo-element ordering: ::before::first-letter → text
  • Only checks previous sibling (index-1) for existing FirstLetter
  • Handles three contexts:
    1. setNodeStyle during DOM building (sets attribute, returns if no children)
    2. onBodyExit during DOM building (finds text node, creates pseudoElem)
    3. Stylesheet re-application (creates pseudoElem if needed)
  • Avoids duplicate creation by checking for existing FirstLetter pseudo-elements

Helper Methods:

  • getFirstLetterPseudoElem(): Finds FirstLetter from text node, checks previous sibling, handles boxing nodes
  • getFirstLetterTextNode(): Finds text node following FirstLetter pseudo-element
  • Both methods check display:none and return NULL if not displayed

Pseudo-element Ordering:

  • Consistent ::before::first-letter → text ordering
  • ::first-letter picks letter from text node (not from ::before content)
  • Simplified, non-spec behavior but more predictable
  • Performance optimized with early exit and single sibling check

XPointer Handling:

  • When offset < src->t.offset, pointer refers to FirstLetter text
  • Uses helper methods to find pseudo-element and handle rectangle retrieval
  • word->t.start stored as absolute position for consistency
  • Fixes text selection highlighting issues

display:none Support:

  • Users can disable with *::first-letter { display: none !important; }
  • FirstLetter elements remain in DOM but ignored during rendering
  • Centralized checking in helper methods

FirstLetter Scope:

  • Only applies when directly preceding text node or wrapped in boxing nodes
  • Regular inline elements not traversed (create own formatting context)

Selector Matching:

  • Finds ancestor with HasFirstLetter attribute
  • Delegates to standard rule-checking code
  • Eliminates code duplication

Text Offset Mechanism:

  • Buffer creation skips first N characters based on offset
  • Word positions stored as absolute (including offset)
  • XPointer offsets and word positions aligned

@Frenzie

Frenzie commented Jan 31, 2026

Copy link
Copy Markdown
Member

Now you can ask Copilot to review this PR. 😄

Comment thread crengine/include/fb2def.h
// Misc other attributes commonly found in books (ie. with cover SVG wrappers)
XS_ATTR( viewBox )
XS_ATTR( preserveAspectRatio )
XS_ATTR2( aria_hidden, "aria-hidden" )

@Frenzie Frenzie Jan 31, 2026

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.

Should a few others like aria-label/labelled-by also be added then? I don't think I've spotted any in the wild, but I have seen it advocated in for example https://www.tpgi.com/using-aria-enhance-svg-accessibility/.

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.

If you feel like compiling those that feel like they can be found in books and providing the list, we can add them, there is room.

Comment thread crengine/src/lvtinydom.cpp Outdated
if ( props & (CH_PROP_UPPER | CH_PROP_LOWER | CH_PROP_DIGIT | CH_PROP_SIGN) ) {
// Found the start of the first letter
foundFirstLetter = true;
i++;

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.

Would this be clearer in the foundFirstLetter block?

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.

Can you be more explicite ?

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.

When I looked at this I thought huh, that's funny, they both do i++. Which made me think I'd either put it before the condition:

                        while ( i < len ) {
                            lUInt16 props = lGetCharProps(text[i]);
                            i++;

Or like:

                            if ( props & (CH_PROP_UPPER | CH_PROP_LOWER | CH_PROP_DIGIT | CH_PROP_SIGN) ) {
                                // Found the start of the first letter
                                foundFirstLetter = true;
// not here
                                break;
                            }
                            i++;
                        }
                        // Now grab any trailing modifiers/punctuation that are part of the first letter
                        if ( foundFirstLetter ) {
// but here
                            i++;
                            while ( i < len ) {

But it's just a an idle thought, do whatever you prefer obviously. ^_^

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.

Yes, I indeed prefer it written like you did.

Handled as another variation of the pseudoElem
internal element we introduced to support ::before
and ::after, so we benefit from its existing CSS
parsing/checking/applying and styles inheritance.

Have it inserted just before the text node it
should pick its first letter from, store in a
FirstLetter= attribute how many chars should be
picked from the next text node and rendered when
we render that pseudoElem, and skipped when
rendering the text node itself.

Known non-conformance to the CSS specs:
- we don't limit which CSS properties can be used
  (the specs enumarate a subset of known properties).
- we may apply it where we should not (ie. among inlines,
  propagate it from paragraph to a first inline-table...)
  (Firefox and Chrome already have different behaviours
  in this regard).
- if there are a ::before and a ::first-letter, we won't
  pick the first letter from that ::before's content, and
  we will render the ::before BEFORE the first-letter
  picked from the following text.

First letters can be disabled with using
::first-leter { display: none; }
('display:' being one of the property the specs say
we should not support, we can use it to disable them
with style tweaks.)
@poire-z poire-z merged commit fab44f6 into koreader:master Feb 1, 2026
1 check passed
@poire-z poire-z deleted the first_letter branch February 1, 2026 08:03
@poire-z

poire-z commented Feb 1, 2026

Copy link
Copy Markdown
Contributor Author

To be bumped after next stable release.

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