Skip to content

get_drawings() returns incorrect lineJoin and width #4954

Closed

Description

@sakamotch

Description of the bug

get_drawings() returns incorrect lineJoin and width values due to two bugs in jm_lineart_stroke_path:

  1. lineJoin is multiplied by pathfactor: stroke->linejoin is an enum (0=Miter, 1=Round, 2=Bevel) and should not be scaled. Note that lineCap is correctly handled as plain integers without pathfactor multiplication, so this is inconsistent.

  2. pathfactor only handles uniform scaling and 90° rotation: For non-uniform scaling (e.g. 2 0 0 3 0 0 cm), pathfactor falls back to 1, making width incorrect.

This bug is hard to notice because linejoin=0 (Miter, the most common value) always produces 0 * pathfactor = 0.

Affected code:

  • src/extra.i: jm_lineart_stroke_path
  • src_classic/helper-devices.i: same function

How to reproduce the bug

Bug 1: lineJoin is scaled by pathfactor

import fitz

doc = fitz.open()
page = doc.new_page(width=200, height=200)
shape = page.new_shape()
shape.draw_line((0, 0), (1, 1))
shape.finish(color=(0, 0, 0), width=0.1)
shape.commit()
content = b"q\n0.12 0 0 0.12 0 0 cm\n2 j\n6 w\n100 100 m\n800 100 l\nS\nQ\n"
doc.update_stream(page.get_contents()[0], content)
pdf_bytes = doc.tobytes()
doc.close()

doc2 = fitz.open(stream=pdf_bytes, filetype="pdf")
d = doc2[0].get_drawings()[-1]
print(f"lineJoin={d['lineJoin']}")  # Expected: 2, Actual: 0.24
doc2.close()
CTM scale linejoin (raw) Expected Actual
0.12 2 (Bevel) 2 0.24
2.0 2 (Bevel) 2 4.0
2.83 1 (Round) 1 2.83

For comparison, lineCap is correctly returned as integers without scaling:

// lineCap - correct (no pathfactor)
Py_BuildValue("iii", stroke->start_cap, stroke->dash_cap, stroke->end_cap)

// lineJoin - incorrect (pathfactor applied)
Py_BuildValue("f", dev->pathfactor * stroke->linejoin)

Bug 2: width is wrong with non-uniform scale

doc = fitz.open()
page = doc.new_page(width=200, height=200)
shape = page.new_shape()
shape.draw_line((0, 0), (1, 1))
shape.finish(color=(0, 0, 0), width=0.1)
shape.commit()
content = b"q\n2 0 0 3 0 0 cm\n1 w\n10 10 m\n90 10 l\nS\nQ\n"
doc.update_stream(page.get_contents()[0], content)
pdf_bytes = doc.tobytes()
doc.close()

doc2 = fitz.open(stream=pdf_bytes, filetype="pdf")
d = doc2[0].get_drawings()[-1]
print(f"width={d['width']}")  # Expected: 2.0, Actual: 1.0
doc2.close()

Suggested fix

lineJoin — use the raw enum value without scaling:

// Before (src/extra.i)
DICT_SETITEMSTR_DROP(dev->pathdict, "lineJoin",
    Py_BuildValue("f", dev->pathfactor * stroke->linejoin));

// After
DICT_SETITEMSTR_DROP(dev->pathdict, "lineJoin",
    Py_BuildValue("i", stroke->linejoin));

pathfactor — use sqrt(a² + b²) to handle arbitrary transforms:

// Before (src/extra.i)
dev->pathfactor = 1;
if (ctm.a != 0 && fz_abs(ctm.a) == fz_abs(ctm.d))
    dev->pathfactor = fz_abs(ctm.a);
else if (ctm.b != 0 && fz_abs(ctm.b) == fz_abs(ctm.c))
    dev->pathfactor = fz_abs(ctm.b);

// After
float scale = sqrtf(ctm.a * ctm.a + ctm.b * ctm.b);
if (scale < 1e-9f)
    scale = sqrtf(ctm.c * ctm.c + ctm.d * ctm.d);
if (scale < 1e-9f)
    scale = 1.0f;
dev->pathfactor = scale;

Both src/extra.i and src_classic/helper-devices.i have the same bugs.

Note: For non-uniform scaling, stroke width is direction-dependent in general.
This fix approximates it using the length of the transformed unit vector.

PyMuPDF version

1.27.2.2

Operating system

Linux

Python version

3.13

Activity

  1. sakamotch commented on Mar 26, 2026

    @sakamotch
    ContributorAuthor

    I've submitted a PR #4955 for this. The CI test failure in test_drawings1 is because I changed lineJoin from Py_BuildValue("f", ...) to Py_BuildValue("i", ...), which changes the type from float to int. I can keep it as float if backward compatibility is preferred — happy to adjust based on your preference.

  2. JorjMcKie commented on Mar 26, 2026

    @JorjMcKie
    Collaborator

    In any case thanks for the PR!
    Yes, please use floats for backward compatibility. We'll approve the PR as soon as tests go through.

  3. sakamotch commented on Mar 26, 2026

    @sakamotch
    ContributorAuthor

    Thanks! Updated to use float for backward compatibility.

  4. julian-smith-artifex-com commented on Apr 16, 2026

    @julian-smith-artifex-com
    Collaborator

    After merging #4955 i realised that we don't have a test for the new behaviour, so i've added two tests in my tree, based on the original reproducer code you provided above.

    For case 1, it changes d['lineJoin'] from 0.24 to 2, as expected.

    But for case 2, it changes d['width'] from 1.0 to 2.449. Is this ok?

    Test code is:

    diff --git a/tests/test_drawings.py b/tests/test_drawings.py
    index 1c2681b4..7f76e1fc 100644
    --- a/tests/test_drawings.py
    +++ b/tests/test_drawings.py
    @@ -228,3 +228,39 @@ def test_3591():
         paths = page.get_drawings()
         for p in paths:
             assert p["width"] == 15
    +
    +
    +def test_4954_1():
    +    path_out = os.path.normpath(f'{__file__}/../../tests/test_4954_1.pdf')
    +    with pymupdf.open() as document:
    +        page = document.new_page(width=200, height=200)
    +        shape = page.new_shape()
    +        shape.draw_line((0, 0), (1, 1))
    +        shape.finish(color=(0, 0, 0), width=0.1)
    +        shape.commit()
    +        content = b'q\n0.12 0 0 0.12 0 0 cm\n2 j\n6 w\n100 100 m\n800 100 l\nS\nQ\n'
    +        document.update_stream(page.get_contents()[0], content, compress=0)
    +        document.save(path_out)
    +
    +    with pymupdf.open(path_out) as document:
    +        d = document[0].get_drawings()[-1]
    +        print(f'{d["lineJoin"]=}')  # Expected: 2, Actual: 0.24
    +        assert d['lineJoin'] == 2
    +
    +
    +def test_4954_2():
    +    path_out = os.path.normpath(f'{__file__}/../../tests/test_4954_2.pdf')
    +    with pymupdf.open() as document:
    +        page = document.new_page(width=200, height=200)
    +        shape = page.new_shape()
    +        shape.draw_line((0, 0), (1, 1))
    +        shape.finish(color=(1, 0, 0), width=0.1)
    +        shape.commit()
    +        content = b'q\n2 0 0 3 0 0 cm\n1 w\n10 10 m\n90 10 l\nS\nQ\n'
    +        document.update_stream(page.get_contents()[0], content, compress=0)
    +        document.save(path_out)
    +
    +    with pymupdf.open(path_out) as document:
    +        d = document[0].get_drawings()[-1]
    +        print(f'{d["width"]=}')  # Expected: 2.0, Actual: 1.0
    +        assert abs(d['width'] - 2.449) < 0.01
    
  5. sakamotch commented on Apr 16, 2026

    @sakamotch
    ContributorAuthor

    Yes, I think this is OK.

    Strictly speaking, with non-uniform scaling there is no single exact stroke width: the effective width depends on the direction of each stroked segment. Since get_drawings() currently returns a single width value for the path, it cannot represent per-segment/per-direction widths.

    So for the current API, using sqrt(abs(a*d - b*c)) as a representative scalar seems reasonable. In this case that gives sqrt(6) = 2.449..., so the proposed test expectation matches the merged implementation.

  6. julian-smith-artifex-com commented on Apr 16, 2026

    @julian-smith-artifex-com
    Collaborator

    Marvellous, thanks.

  7. added 5 commits that reference this issue on Apr 17, 2026
  8. julian-smith-artifex-com commented on Apr 24, 2026

    @julian-smith-artifex-com
    Collaborator

    Fixed in pymupdf-1.27.2.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions