Skip to content

write_html: make an id or <a name> a target for href="#..." links, li… - #2048

Open
RavSinghChandan wants to merge 5 commits into
py-pdf:masterfrom
RavSinghChandan:html-anchor-targets
Open

RavSinghChandan wants to merge 5 commits into
py-pdf:masterfrom
RavSinghChandan:html-anchor-targets

Conversation

@RavSinghChandan

Copy link
Copy Markdown

A link to a place in the same document, such as a table of contents with <a href="#intro">, made write_html() fail at output with FPDFException: Named destination 'intro' was referenced but never set, because nothing in the HTML created that destination. Browsers use the id of any element, or the name of an <a>, as the target.

write_html() now registers the id of an element, and the name of an <a>, as a named destination at its position, using the existing set_link(name=...), so these links work, including to a later page. A link to a target that does not exist still raises as before.

test_html_anchor_without_href (from #1984) now also defines the two destinations in its expected document: the anchors still render as plain text, and the PDF now contains their destinations. Added tests for id and <a name> targets, and for a target on a later page.

Comment thread fpdf/html.py Resolved
@RavSinghChandan

Copy link
Copy Markdown
Author

Thanks, you were right on both points. I checked: a heading that did not fit at the bottom of the page was rendered on page 2 while its destination pointed to page 1.

Now the targets are collected while parsing and set when the first line of the paragraph that holds them is rendered (in TextColumns._render_column_lines, after the page break check), so the destination is on the page where the text really is. An element with both an id and a name registers both, as in your example. A target inside a table uses the table position, since cells are not rendered as paragraphs.

Added tests for your id + name example and for a heading moved to the next page. I also rebased on master.

Comment thread fpdf/html.py
and anchor.strip() not in self._pending_anchors
):
self._pending_anchors.append(anchor.strip())
if tag == "table" or self.table is not None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block runs after the img branch, so <img id="img"> stays pending and is flushed at the next paragraph or at the end of feed(). The destination then points below the image: with <a href="#img">go</a><img id="img" ...>, named_destinations["img"].top is the image bottom, not the image top. Flushing pending anchors before the img branch would anchor the image correctly.

@RavSinghChandan

Copy link
Copy Markdown
Author

Good catch, thanks. Confirmed: with go<img id="img" ...> the destination was at the bottom of the image.

Targets are now collected at the start of the tag, and for an they are set right after it is drawn, at the top of the image and on the page where it lands, so an image moved to the next page by a page break is handled too. An inside a table cell uses the table position, like the other targets in tables.

Added a test with your example that compares the destination with where the image is actually drawn, on the same page and after a page break.

@agustin18

Copy link
Copy Markdown

Confirmed on the latest commit (daaaa4b): the original <a href="#img">...<img id="img"> repro now points at the top of the image, the moved-to-next-page case lands on the correct page, and all 160 test/html/test_html.py tests pass locally. The id + name and table cases also behave as described. Nice fix.

@RavSinghChandan

Copy link
Copy Markdown
Author

Thanks a lot for testing it so carefully, @agustin18! Glad the image and page break cases work now.

Comment thread test/html/test_html.py
assert destination.page_number == page == (2 if space_left else 1)
assert destination.top == pytest.approx((pdf.h - top) * pdf.k)


@andersonhc andersonhc Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def test_html_inline_anchor_after_a_page_break():
    """An inline target points to its own line, not the paragraph's first line."""
    pdf = FPDF()
    pdf.add_page()
    pdf.write_html(
        '<a href="#target">Go to target</a>'
        "<p>"
        + "words " * 1100
        + '<a id="target">TARGET</a></p>'
    )
    pdf.output()

    assert pdf.pages_count == 2
    assert pdf.named_destinations["target"].page_number == 2

def test_html_anchor_on_a_table_moved_to_the_next_page():
    """A table target follows the table when layout causes a page break."""
    pdf = FPDF()
    pdf.add_page()
    pdf.set_y(pdf.h - pdf.b_margin - 3)
    pdf.write_html(
        '<table id="target"><tr><td>TARGET</td></tr></table>'
        '<a href="#target">Back to table</a>'
    )
    pdf.output()

    assert pdf.pages_count == 2
    destination = pdf.named_destinations["target"]
    assert destination.page_number == 2
    assert destination.top == pytest.approx((pdf.h - pdf.t_margin) * pdf.k)

@RavSinghChandan

Copy link
Copy Markdown
Author

Thanks for the two tests, both failed and both pass now (added as you wrote them).

Inline targets: a target is now attached to the first character of the text that follows it. When lines are broken, each line records the targets whose character starts on it, and the destination is set when that line is rendered, so it points to the target's own line and page, also after a page break in the middle of a paragraph.

Tables: Table.render() now records where its first row is rendered (first_row_position), and write_html() sets the table's targets there after rendering, so they follow the table to the next page.

I also checked that an inline target lands on the exact line where its text is drawn, including when its text starts with a space that is skipped at the start of a line.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants