feat: Add percentage support to CSS properties (#738)

## Summary
- Closes #730

**What is the goal of this PR?**
- Adds percentage-based value support to CSS properties that accept
percentages (padding, margin, text-indent)
 
**What changes are included?**
- Adds `Percent` as another CSS unit
- Passes the viewport width to `fromCssStyle` so that we can resolve
percentage-based values
- Adds a fallback of using an emspace for text-indent if we have an
unresolvable value for whatever reason

## Additional Context

- This was missed in my CSS support feature, and the fallback when we
encounter a percentage value is to use px instead. This means 5% (which
would be ~30px on the screen) turns into 5px. When percentages are used
in `text-indent`, this fallback behavior makes the indent look like a
single space character. Whoops! 😬

My test EPUB has been updated
[here](https://github.com/jdk2pq/css-test-epub) with percentage based
CSS values at the end of the book.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
This commit is contained in:
Jake Kenneally
2026-02-08 16:31:52 -05:00
committed by Dave Allie
parent def1094411
commit 9e04eec072
4 changed files with 38 additions and 19 deletions

View File

@@ -64,21 +64,27 @@ struct BlockStyle {
// Create a BlockStyle from CSS style properties, resolving CssLength values to pixels // Create a BlockStyle from CSS style properties, resolving CssLength values to pixels
// emSize is the current font line height, used for em/rem unit conversion // emSize is the current font line height, used for em/rem unit conversion
// paragraphAlignment is the user's paragraphAlignment setting preference // paragraphAlignment is the user's paragraphAlignment setting preference
static BlockStyle fromCssStyle(const CssStyle& cssStyle, const float emSize, const CssTextAlign paragraphAlignment) { static BlockStyle fromCssStyle(const CssStyle& cssStyle, const float emSize, const CssTextAlign paragraphAlignment,
const uint16_t viewportWidth = 0) {
BlockStyle blockStyle; BlockStyle blockStyle;
// Resolve all CssLength values to pixels using the current font's em size const float vw = viewportWidth;
blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize); // Resolve all CssLength values to pixels using the current font's em size and viewport width
blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize); blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize, vw);
blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize); blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize, vw);
blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize); blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize, vw);
blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize, vw);
blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize); blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize, vw);
blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize); blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize, vw);
blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize); blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize, vw);
blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize); blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize, vw);
blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize); // For textIndent: if it's a percentage we can't resolve (no viewport width),
blockStyle.textIndentDefined = cssStyle.hasTextIndent(); // leave textIndentDefined=false so the EmSpace fallback in applyParagraphIndent() is used
if (cssStyle.hasTextIndent() && cssStyle.textIndent.isResolvable(vw)) {
blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize, vw);
blockStyle.textIndentDefined = true;
}
blockStyle.textAlignDefined = cssStyle.hasTextAlign(); blockStyle.textAlignDefined = cssStyle.hasTextAlign();
// User setting overrides CSS, unless "Book's Style" alignment setting is selected // User setting overrides CSS, unless "Book's Style" alignment setting is selected
if (paragraphAlignment == CssTextAlign::None) { if (paragraphAlignment == CssTextAlign::None) {

View File

@@ -283,6 +283,8 @@ CssLength CssParser::interpretLength(const std::string& val) {
unit = CssUnit::Rem; unit = CssUnit::Rem;
} else if (unitPart == "pt") { } else if (unitPart == "pt") {
unit = CssUnit::Points; unit = CssUnit::Points;
} else if (unitPart == "%") {
unit = CssUnit::Percent;
} }
// px and unitless default to Pixels // px and unitless default to Pixels
@@ -518,7 +520,7 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
// Cache serialization // Cache serialization
// Cache format version - increment when format changes // Cache format version - increment when format changes
constexpr uint8_t CSS_CACHE_VERSION = 1; constexpr uint8_t CSS_CACHE_VERSION = 2;
bool CssParser::saveToCache(FsFile& file) const { bool CssParser::saveToCache(FsFile& file) const {
if (!file) { if (!file) {

View File

@@ -4,7 +4,7 @@
// Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings // Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings
enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3, None = 4 }; enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3, None = 4 };
enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3 }; enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3, Percent = 4 };
// Represents a CSS length value with its unit, allowing deferred resolution to pixels // Represents a CSS length value with its unit, allowing deferred resolution to pixels
struct CssLength { struct CssLength {
@@ -17,21 +17,32 @@ struct CssLength {
// Convenience constructor for pixel values (most common case) // Convenience constructor for pixel values (most common case)
explicit CssLength(const float pixels) : value(pixels) {} explicit CssLength(const float pixels) : value(pixels) {}
// Returns true if this length can be resolved to pixels with the given context.
// Percentage units require a non-zero containerWidth to resolve.
[[nodiscard]] bool isResolvable(const float containerWidth = 0) const {
return unit != CssUnit::Percent || containerWidth > 0;
}
// Resolve to pixels given the current em size (font line height) // Resolve to pixels given the current em size (font line height)
[[nodiscard]] float toPixels(const float emSize) const { // containerWidth is needed for percentage units (e.g. viewport width)
[[nodiscard]] float toPixels(const float emSize, const float containerWidth = 0) const {
switch (unit) { switch (unit) {
case CssUnit::Em: case CssUnit::Em:
case CssUnit::Rem: case CssUnit::Rem:
return value * emSize; return value * emSize;
case CssUnit::Points: case CssUnit::Points:
return value * 1.33f; // Approximate pt to px conversion return value * 1.33f; // Approximate pt to px conversion
case CssUnit::Percent:
return value * containerWidth / 100.0f;
default: default:
return value; return value;
} }
} }
// Resolve to int16_t pixels (for BlockStyle fields) // Resolve to int16_t pixels (for BlockStyle fields)
[[nodiscard]] int16_t toPixelsInt16(const float emSize) const { return static_cast<int16_t>(toPixels(emSize)); } [[nodiscard]] int16_t toPixelsInt16(const float emSize, const float containerWidth = 0) const {
return static_cast<int16_t>(toPixels(emSize, containerWidth));
}
}; };
// Font style options matching CSS font-style property // Font style options matching CSS font-style property

View File

@@ -213,12 +213,12 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} }
const float emSize = static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression; const float emSize = static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
const auto userAlignmentBlockStyle = const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle(
BlockStyle::fromCssStyle(cssStyle, emSize, static_cast<CssTextAlign>(self->paragraphAlignment)); cssStyle, emSize, static_cast<CssTextAlign>(self->paragraphAlignment), self->viewportWidth);
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
self->currentCssStyle = cssStyle; self->currentCssStyle = cssStyle;
auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center); auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center, self->viewportWidth);
headerBlockStyle.textAlignDefined = true; headerBlockStyle.textAlignDefined = true;
if (self->embeddedStyle && cssStyle.hasTextAlign()) { if (self->embeddedStyle && cssStyle.hasTextAlign()) {
headerBlockStyle.alignment = cssStyle.textAlign; headerBlockStyle.alignment = cssStyle.textAlign;