Combine approaches with #1454

This commit is contained in:
Zach Nelson
2026-04-16 00:39:35 -05:00
parent 206bfe760e
commit 8b940db781
2 changed files with 82 additions and 48 deletions
+46 -28
View File
@@ -23,42 +23,60 @@ struct BlockStyle {
bool textIndentDefined = false; // true if text-indent was explicitly set in CSS
bool textAlignDefined = false; // true if text-align was explicitly set in CSS
// Combined horizontal insets (margin + padding)
// Combined insets (margin + padding)
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
[[nodiscard]] int16_t totalHorizontalInset() const { return leftInset() + rightInset(); }
[[nodiscard]] int16_t topInset() const { return marginTop + paddingTop; }
[[nodiscard]] int16_t bottomInset() const { return marginBottom + paddingBottom; }
// Combine with another block style. Useful for parent -> child styles, where the child style should be
// applied on top of the parent's style to get the combined style.
BlockStyle getCombinedBlockStyle(const BlockStyle& child) const {
BlockStyle combinedBlockStyle;
// Return a copy with bottom margins/padding zeroed out.
[[nodiscard]] BlockStyle withoutBottom() const {
BlockStyle result = *this;
result.marginBottom = 0;
result.paddingBottom = 0;
return result;
}
combinedBlockStyle.marginTop = static_cast<int16_t>(child.marginTop + marginTop);
combinedBlockStyle.marginBottom = static_cast<int16_t>(child.marginBottom + marginBottom);
combinedBlockStyle.marginLeft = static_cast<int16_t>(child.marginLeft + marginLeft);
combinedBlockStyle.marginRight = static_cast<int16_t>(child.marginRight + marginRight);
// Return a copy with the source's bottom margins/padding added to this style's.
[[nodiscard]] BlockStyle addBottom(const BlockStyle& source) const {
BlockStyle result = *this;
result.marginBottom = static_cast<int16_t>(marginBottom + source.marginBottom);
result.paddingBottom = static_cast<int16_t>(paddingBottom + source.paddingBottom);
return result;
}
combinedBlockStyle.paddingTop = static_cast<int16_t>(child.paddingTop + paddingTop);
combinedBlockStyle.paddingBottom = static_cast<int16_t>(child.paddingBottom + paddingBottom);
combinedBlockStyle.paddingLeft = static_cast<int16_t>(child.paddingLeft + paddingLeft);
combinedBlockStyle.paddingRight = static_cast<int16_t>(child.paddingRight + paddingRight);
// Text indent: use child's if defined
if (child.textIndentDefined) {
combinedBlockStyle.textIndent = child.textIndent;
combinedBlockStyle.textIndentDefined = true;
enum class CombineAxis : uint8_t {
Horizontal = 1, // margins left/right, padding left/right, text-align, text-indent
Vertical = 2, // margins top/bottom, padding top/bottom
};
// Combine this style's properties with a child style along the specified axis.
// Properties on the other axis are kept from the child unchanged.
[[nodiscard]] BlockStyle getCombinedBlockStyle(const BlockStyle& child, CombineAxis axis) const {
BlockStyle result = child;
if (axis == CombineAxis::Horizontal) {
result.marginLeft = static_cast<int16_t>(child.marginLeft + marginLeft);
result.marginRight = static_cast<int16_t>(child.marginRight + marginRight);
result.paddingLeft = static_cast<int16_t>(child.paddingLeft + paddingLeft);
result.paddingRight = static_cast<int16_t>(child.paddingRight + paddingRight);
if (!child.textIndentDefined && textIndentDefined) {
result.textIndent = textIndent;
result.textIndentDefined = true;
}
if (!child.textAlignDefined && textAlignDefined) {
result.alignment = alignment;
result.textAlignDefined = true;
}
} else {
combinedBlockStyle.textIndent = textIndent;
combinedBlockStyle.textIndentDefined = textIndentDefined;
result.marginTop = static_cast<int16_t>(child.marginTop + marginTop);
result.marginBottom = static_cast<int16_t>(child.marginBottom + marginBottom);
result.paddingTop = static_cast<int16_t>(child.paddingTop + paddingTop);
result.paddingBottom = static_cast<int16_t>(child.paddingBottom + paddingBottom);
}
// Text align: use child's if defined
if (child.textAlignDefined) {
combinedBlockStyle.alignment = child.alignment;
combinedBlockStyle.textAlignDefined = true;
} else {
combinedBlockStyle.alignment = alignment;
combinedBlockStyle.textAlignDefined = textAlignDefined;
}
return combinedBlockStyle;
return result;
}
// Create a BlockStyle from CSS style properties, resolving CssLength values to pixels
+36 -20
View File
@@ -130,9 +130,13 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
if (currentTextBlock) {
// already have a text block running and it is empty - just reuse it
if (currentTextBlock->isEmpty()) {
// Set the block style directly. Callers from block/header element opens pass the
// fully accumulated style (parent stack + this element) so merging is not needed.
currentTextBlock->setBlockStyle(blockStyle);
// The stack accumulates horizontal margins and text properties from ancestors.
// Vertical margins are per-element and not inherited through the stack, but
// container elements deposit their vertical margins on the empty block when they
// open. Merge those into the new style so the first child in a container inherits
// the container's vertical spacing.
currentTextBlock->setBlockStyle(
currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical));
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
@@ -434,16 +438,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->startNewTextBlock(parentBlockStyle);
}
// Apply vertical margins from the current (possibly empty) text block.
// When an image is inside a container like <div style="margin:1em 40%">,
// the div's margins live on the empty text block but are never flushed
// via makePages(). Apply them here so the image respects vertical spacing.
// Apply vertical margins from the container to the image.
// Top margin lives on the empty text block (deposited via vertical merge
// in startNewTextBlock). Bottom margin was stripped by withoutBottom() for
// deferred application at element close, so read it from the stack.
int16_t imageMarginTop = 0;
int16_t imageMarginBottom = 0;
if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
const auto& bs = self->currentTextBlock->getBlockStyle();
imageMarginTop = static_cast<int16_t>(bs.marginTop + bs.paddingTop);
imageMarginBottom = static_cast<int16_t>(bs.marginBottom + bs.paddingBottom);
imageMarginTop = bs.topInset();
if (self->blockStyleStack.size() > 1) {
imageMarginBottom = self->blockStyleStack.back().bottomInset();
}
}
// Create page for image - only break if image won't fit remaining space
@@ -500,7 +506,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// Fallback to alt text if image processing fails
if (!alt.empty()) {
alt = "[Image: " + alt + "]";
self->startNewTextBlock(self->blockStyleStack.back().getCombinedBlockStyle(centeredBlockStyle));
self->startNewTextBlock(
self->blockStyleStack.back()
.getCombinedBlockStyle(centeredBlockStyle, BlockStyle::CombineAxis::Horizontal)
.withoutBottom());
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
self->depth += 1;
self->characterData(userData, alt.c_str(), alt.length());
@@ -590,9 +599,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (self->embeddedStyle && cssStyle.hasTextAlign()) {
headerBlockStyle.alignment = cssStyle.textAlign;
}
const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(headerBlockStyle);
const auto accumulated =
self->blockStyleStack.back().getCombinedBlockStyle(headerBlockStyle, BlockStyle::CombineAxis::Horizontal);
self->blockStyleStack.push_back(accumulated);
self->startNewTextBlock(accumulated);
self->startNewTextBlock(accumulated.withoutBottom());
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
self->updateEffectiveInlineStyle();
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
@@ -601,12 +611,13 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
self->flushPartWordBuffer();
}
self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
self->startNewTextBlock(self->blockStyleStack.back().withoutBottom());
} else {
self->currentCssStyle = cssStyle;
const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(userAlignmentBlockStyle);
const auto accumulated =
self->blockStyleStack.back().getCombinedBlockStyle(userAlignmentBlockStyle, BlockStyle::CombineAxis::Horizontal);
self->blockStyleStack.push_back(accumulated);
self->startNewTextBlock(accumulated);
self->startNewTextBlock(accumulated.withoutBottom());
self->updateEffectiveInlineStyle();
if (strcmp(name, "li") == 0) {
@@ -999,15 +1010,20 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->currentCssStyle.reset();
self->updateEffectiveInlineStyle();
// Pop this element's contribution from the block style stack and restore the
// parent's accumulated style on empty blocks. This prevents closed elements'
// styles (alignment, margins, padding) from bleeding into siblings while
// correctly preserving ancestor styles for subsequent children.
// br is self-closing and not a container — it doesn't push/pop the stack.
if (strcmp(name, "br") != 0 && self->blockStyleStack.size() > 1) {
// Apply closing element's bottom margin to the current text block so
// container spacing appears after the element's content (on the last child),
// not on the first child via the empty-block merge in startNewTextBlock.
if (self->currentTextBlock) {
self->currentTextBlock->setBlockStyle(
self->currentTextBlock->getBlockStyle().addBottom(self->blockStyleStack.back()));
}
self->blockStyleStack.pop_back();
// Restore parent's accumulated style on empty blocks to prevent the closed
// element's styles from bleeding into the next sibling.
if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
self->currentTextBlock->setBlockStyle(self->blockStyleStack.back());
self->currentTextBlock->setBlockStyle(self->blockStyleStack.back().withoutBottom());
}
}
}