Skip to content

font-size gets PX_TO_PT applied twice, text renders 0.75x too small relative to the geometry #502

Description

@Zunhammer

Same thing as #471, which was closed as expected behaviour. I don't think it is, and I can't reopen that one, so I'm filing it separately with the extra detail I dug up. The README describes the right rule, the code just doesn't implement it.

We hit this while evaluating an upgrade of a floor plan report generator from 1.5.1. The geometry came out fine, but every label was clearly too small against the drawing it belongs to, and I ended up in the same place @blayzen-w did.

The reason this is easy to get wrong (I got it wrong first time round too): the Tf operator really does say 12 for font-size="16", exactly like the README says. But it sits inside a cm matrix that already carries the 0.75, so what actually gets rendered is 9pt.

Repro, only needs svglib + reportlab:

import io, os, re, tempfile
from svglib.svglib import svg2rlg
from reportlab.graphics import renderPDF
from reportlab.pdfgen import canvas

svg = """<svg width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
  <rect x="0" y="0" width="48" height="8" fill="black"/>
  <text x="10" y="50" font-size="16" font-family="Helvetica">HHHH</text>
</svg>"""

f = tempfile.NamedTemporaryFile("w", suffix=".svg", delete=False, encoding="utf-8")
f.write(svg)
f.close()
d = svg2rlg(f.name)
os.unlink(f.name)

buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(d.width, d.height))
c.setPageCompression(0)   # so the content stream stays readable
renderPDF.draw(d, c, 0, 0)
c.save()

print("Drawing:", d.width, "x", d.height)
stream = max(re.findall(rb"stream\r?\n(.*?)endstream", buf.getvalue(), re.S), key=len)
for line in stream.decode("latin-1").splitlines():
    if line.strip():
        print(line.strip())

The bar is 48 units wide and the font-size is 16, so in any renderer the text is a third of the bar.

On 2.1.0 (trimmed to the interesting lines):

Drawing: 72.0 x 72.0
...
.75 0 0 -0.75 0 72 cm
n 0 0 48 8 re f
1 0 0 -1 0 0 cm
BT /F1 12 Tf 14.4 TL ET
BT 1 0 0 1 10 -50 Tm (HHHH) Tj T* ET

The .75 matrix applies to everything below it. The rect comes out 36pt wide, which is right. The text comes out at 12 * 0.75 = 9pt, which isn't. That's a quarter of the bar instead of a third.

Same script on 1.5.1:

Drawing: 96.0 x 96.0
...
1 0 0 -1 0 96 cm
n 0 0 48 8 re f*
1 0 0 -1 0 0 cm
BT /F1 16 Tf 19.2 TL ET
BT 1 0 0 1 10 -50 Tm (HHHH) Tj T* ET

48pt bar, 16pt text.

I rendered the same file in Chrome as well, mostly to check I wasn't just preferring the old behaviour out of habit. Measuring cap height against bar width: Chrome 0.2396, svglib 1.5.1 0.2396, svglib 2.1.0 0.1806. 1.5.1 is pixel identical to the browser, 2.1.0 is off by the 0.75.

Versions I tried:

1.5.1 ok
1.6.0 ok
2.0.2 wrong
2.1.0 wrong

So it arrived with the unit rework in 2.0b1, which matches what @blayzen-w suspected.

In svglib.py at v2.1.0:

# L1837-1838, the viewport matrix already has PX_TO_PT in it
x_scale = (width * PX_TO_PT) / view_box.width if view_box.width else 1
y_scale = (height * PX_TO_PT) / view_box.height if view_box.height else 1

# L2192-2193, and font-size gets it again
fs = cast(float, attrConv.convertLength(fs_attr))   # user units
fs_pt = fs * PX_TO_PT

# L2196, while x/y stay in user units
x, y = self.convert_length_attrs(node, "x", "y", em_base=fs)

fs_pt goes into String.fontSize, and that String sits inside the group carrying the viewport matrix, so it picks up PX_TO_PT a second time. The x/y three lines below stay in user units, which is where the mismatch comes from.

One other thing worth mentioning: the README suggests scaling the returned Drawing by 4/3 to get pre-2.0 sizing back. That doesn't help here, because it scales text and geometry alike and the ratio stays wrong. The only workaround I found is walking the Drawing and scaling String.fontSize on its own.

I did look at fixing it, but I'm not sure which way you'd want it done. Dropping the * PX_TO_PT at L2193 looks like the obvious move, except fs_pt also feeds the stringWidth() calls that build frag_lengths, and those end up in x positions that are in user units, so multi-tspan text would start shifting instead. The other option would be keeping fs_pt for the metrics and dividing it back out where the String is created. Happy to have a go at a PR if you have a preference.

Tested on Python 3.13 / Windows, with reportlab 4.4.6 and 5.0.0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions