Skip to content

Commit 4b01dab

Browse files
committed
Fix KFX title width handling
1 parent 8a4a2e2 commit 4b01dab

5 files changed

Lines changed: 81 additions & 21 deletions

File tree

convert/kfx/frag_block_builder.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,11 @@ func (sb *StorylineBuilder) StartBlock(styleSpec string, styles *StyleRegistry,
9090

9191
// Create StyleContext for child resolution - children will be counted in EndBlock.
9292
// Note: Use Push (not PushBlock) so container margins (ml/mr) stay on the wrapper
93-
// unless explicitly inherited via CSS.
94-
ctx := NewStyleContext(styles).Push("div", styleSpec)
93+
// unless explicitly inherited via CSS. Strip synthetic root horizontal margins
94+
// from the child context as well: root/content insets belong to the generated
95+
// wrapper level, not to each nested child. Applying the same root inset to
96+
// children makes title blocks wider than normal flow content.
97+
ctx := NewStyleContext(styles).WithoutRootHorizontalMargins().Push("div", styleSpec)
9598

9699
sb.blockStack = append(sb.blockStack, &BlockBuilder{
97100
styleSpec: styleSpec,

convert/kfx/frag_storyline_paragraph.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,8 +516,14 @@ func addParagraphWithMixedContent(c *content.Content, para *fb2.Paragraph, ctx S
516516
return
517517
}
518518

519-
// Create inline image style with em-based dimensions
520-
imgStyle, _ := inlineCtx.ResolveImageWithDimensions(ImageInline, imgInfo.Width, imgInfo.Height, "")
519+
// Create inline image style. In heading/title contexts, keep image art
520+
// independent from the heading font-size; otherwise title images are
521+
// magnified relative to ordinary paragraph images.
522+
imageKind := ImageInline
523+
if headingLevel > 0 {
524+
imageKind = ImageInlineFixed
525+
}
526+
imgStyle, _ := inlineCtx.ResolveImageWithDimensions(imageKind, imgInfo.Width, imgInfo.Height, "")
521527
items = append(items, InlineContentItem{
522528
IsImage: true,
523529
ResourceName: imgInfo.ResourceName,

convert/kfx/style_context.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ type emptyLineState struct {
5252
type ImageKind int
5353

5454
const (
55-
ImageBlock ImageKind = iota // Standalone block image (centered, width%)
56-
ImageInline // Inline within text (em dimensions, baseline-style)
55+
ImageBlock ImageKind = iota // Standalone block image (centered, width%)
56+
ImageInline // Inline within text (em dimensions, baseline-style)
57+
ImageInlineFixed // Inline art that should not scale with current font-size
5758
)
5859

5960
// StyleContext accumulates inherited CSS properties as we descend the element hierarchy.

convert/kfx/style_context_image.go

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func (sc StyleContext) ResolveImage(classes string) string {
3838
// This unifies all dimension-based image styling, applying position filtering consistently.
3939
//
4040
// Parameters:
41-
// - kind: ImageBlock or ImageInline
41+
// - kind: ImageBlock, ImageInline, or ImageInlineFixed
4242
// - imageWidth, imageHeight: pixel dimensions (height only used for ImageInline)
4343
// - blockStyle: for block images, optional style spec to inherit from (e.g., "image", "subtitle")
4444
// If blockStyle contains "image", this is a standalone block image.
@@ -56,7 +56,8 @@ func (sc StyleContext) ResolveImage(classes string) string {
5656
// - Centered only if block has text-align: center
5757
//
5858
// Inline image behavior:
59-
// - Uses em dimensions (width/height converted from pixels using 16px base)
59+
// - ImageInline uses em dimensions (scales with the current text font-size)
60+
// - ImageInlineFixed uses rem dimensions (does not scale with heading/title font-size)
6061
// - baseline-style: center for vertical alignment within text
6162
// - Applies properties from "image-inline" CSS style
6263
// - No margin collapsing (inline images don't participate in margin collapsing)
@@ -70,21 +71,28 @@ func (sc StyleContext) ResolveImageWithDimensions(kind ImageKind, imageWidth, im
7071

7172
props := make(map[KFXSymbol]any)
7273

73-
if kind == ImageInline {
74-
return sc.resolveInlineImage(props, imageWidth, imageHeight), false
74+
if kind == ImageInline || kind == ImageInlineFixed {
75+
return sc.resolveInlineImage(props, imageWidth, imageHeight, kind == ImageInlineFixed), false
7576
}
7677

7778
return sc.resolveBlockImage(props, imageWidth, blockStyle)
7879
}
7980

80-
// resolveInlineImage handles ImageInline styling.
81-
// Inline images use em dimensions and baseline-style for text alignment.
82-
func (sc StyleContext) resolveInlineImage(props map[KFXSymbol]any, imageWidth, imageHeight int) string {
83-
const baseFontSizePx = 16.0 // Standard em base size
84-
85-
// Convert pixel dimensions to em (using 16px base)
86-
widthEm := float64(imageWidth) / baseFontSizePx
87-
heightEm := float64(imageHeight) / baseFontSizePx
81+
// resolveInlineImage handles inline image styling.
82+
// Inline images use baseline-style for text alignment. Most inline images use em
83+
// dimensions so word-art inside normal text scales with the surrounding font.
84+
// Title art uses fixed rem dimensions so h1/h2 font-size does not magnify the
85+
// source image beyond the normal text column.
86+
func (sc StyleContext) resolveInlineImage(props map[KFXSymbol]any, imageWidth, imageHeight int, fixed bool) string {
87+
const baseFontSizePx = 16.0 // Standard CSS pixel base size
88+
89+
// Convert pixel dimensions using a 16px base.
90+
width := float64(imageWidth) / baseFontSizePx
91+
height := float64(imageHeight) / baseFontSizePx
92+
unit := SymUnitEm
93+
if fixed {
94+
unit = SymUnitRem
95+
}
8896

8997
// Apply properties from "image-inline" CSS style
9098
sc.registry.EnsureBaseStyle("image-inline")
@@ -104,9 +112,9 @@ func (sc StyleContext) resolveInlineImage(props map[KFXSymbol]any, imageWidth, i
104112
}
105113

106114
// Add KFX-specific inline image properties
107-
props[SymBaselineStyle] = SymbolValue(SymCenter) // baseline-style: center
108-
props[SymWidth] = DimensionValue(widthEm, SymUnitEm) // width in em
109-
props[SymHeight] = DimensionValue(heightEm, SymUnitEm) // height in em
115+
props[SymBaselineStyle] = SymbolValue(SymCenter) // baseline-style: center
116+
props[SymWidth] = DimensionValue(width, unit)
117+
props[SymHeight] = DimensionValue(height, unit)
110118

111119
return sc.registry.RegisterResolved(props, 0, true)
112120
}

convert/kfx/style_context_root_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,46 @@ func TestNewStyleContext_RootVerticalMarginsIgnored(t *testing.T) {
9090
}
9191
}
9292

93+
func TestStartBlock_DoesNotApplyRootMarginsToTitleChildren(t *testing.T) {
94+
registry, _ := parseAndCreateRegistry([]byte(`
95+
html { margin-left: -0.5em; margin-right: -0.5em; }
96+
body { margin-left: -0.25em; margin-right: -0.25em; }
97+
`), nil, zap.NewNop())
98+
99+
title := &fb2.Title{Items: []fb2.TitleItem{{Paragraph: &fb2.Paragraph{Text: []fb2.InlineSegment{{
100+
Kind: fb2.InlineText,
101+
Text: "Wide title text",
102+
}}}}}}
103+
sb := NewStorylineBuilder("l1", "c1", 1, registry)
104+
sb.StartBlock("chapter-title", registry, nil)
105+
titleCtx := NewStyleContext(registry).Push("div", "chapter-title")
106+
addTitleAsHeading(&content.Content{}, title, titleCtx, "chapter-title-header", 1, sb, registry, nil, NewContentAccumulator(1), nil)
107+
sb.EndBlock()
108+
sb.Build()
109+
110+
rootProps := resolvedStyleProps(t, registry, "", "")
111+
requireMeasure(t, rootProps, SymMarginLeft, -0.75, SymUnitEm)
112+
requireMeasure(t, rootProps, SymMarginRight, -0.75, SymUnitEm)
113+
114+
if len(sb.contentEntries) != 1 {
115+
t.Fatalf("expected one title wrapper, got %d", len(sb.contentEntries))
116+
}
117+
wrapper := sb.contentEntries[0]
118+
if len(wrapper.childRefs) != 1 {
119+
t.Fatalf("expected one title child, got %d", len(wrapper.childRefs))
120+
}
121+
childStyle, ok := registry.Get(wrapper.childRefs[0].Style)
122+
if !ok {
123+
t.Fatalf("child style %q not registered", wrapper.childRefs[0].Style)
124+
}
125+
if _, ok := childStyle.Properties[SymMarginLeft]; ok {
126+
t.Fatalf("root margin-left leaked into title child: %v", childStyle.Properties[SymMarginLeft])
127+
}
128+
if _, ok := childStyle.Properties[SymMarginRight]; ok {
129+
t.Fatalf("root margin-right leaked into title child: %v", childStyle.Properties[SymMarginRight])
130+
}
131+
}
132+
93133
func TestAddTitleWithInlineImage_DoesNotApplyRootMarginsToTitleParagraph(t *testing.T) {
94134
registry, _ := parseAndCreateRegistry([]byte(`
95135
html { margin-left: -0.5em; margin-right: -0.5em; }
@@ -151,6 +191,8 @@ func TestAddTitleWithInlineImage_DoesNotApplyRootMarginsToTitleParagraph(t *test
151191
if _, ok := imageStyle.Properties[SymMarginRight]; ok {
152192
t.Fatalf("root margin-right leaked into inline title image: %v", imageStyle.Properties[SymMarginRight])
153193
}
194+
requireMeasure(t, imageStyle.Properties, SymWidth, 40, SymUnitRem)
195+
requireMeasure(t, imageStyle.Properties, SymHeight, 5, SymUnitRem)
154196
}
155197

156198
func TestAddBacklinkParagraph_InlineEventStyleDropsHorizontalMargins(t *testing.T) {

0 commit comments

Comments
 (0)