ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-09-19Beginner

I Asked for the Whole Quote PDF Tool at Once, Then Split It Into Four Requests

How I moved my client quote paperwork from copy-and-edit to a small PDF tool built with Antigravity, why the first single-shot request had to be thrown away, and the four smaller requests that replaced it, with working code.

Antigravity373PDF4indie development20automation94Python15

Copy last month's quote, change the client name and the numbers, send it. That was my process for years of freelance site work. The evening it nearly caught up with me, I spotted a quote where everything was new except the total at the bottom, which still belonged to the previous job.

What unsettled me afterwards was that I could not reconstruct how I had caught it. My eye happened to land there. There is no reason it would land there next time.

That weekend I asked Antigravity for a small tool that turns a quote into a PDF. The first attempt was mostly thrown away.

Asking for Everything at Once Hid the Bug

My first prompt was greedy, reading it back now. Take the line items, calculate the amounts, lay them out with my logo, render a PDF, and save it with a dated filename — all of it in one message.

What came back did run. It ran, and the total on the PDF was one yen off from my calculator.

The trouble was that I could not trace where that one yen appeared. The arithmetic, the display formatting, and the PDF conversion all lived in the same file, so there was no way to see which step had rounded. When I told the agent the total was off, it added rounding somewhere else, the total matched, and the per-line subtotals stopped matching instead.

Looking back, my mistake was not that I asked for too much. It was that I asked for a chunk larger than anything I could verify. Had I cut it into pieces I could check one at a time, the drift would have announced its own location.

I've written about sizing requests from a different angle in measuring a request before you press send, but what I needed that evening was something plainer.

Four Requests: Data, Arithmetic, Layout, Filename

I split the rebuild into four prompts. The dividing line was not features — it was whether I could confirm each piece on its own.

OrderWhat I asked forHow I checked it
1Decide the JSON shape of a quoteCould I transcribe three real past quotes into it?
2The amount calculation as a standalone functionDoes it agree with my calculator?
3An HTML template rendered to PDFDoes the print preview hold together?
4Output folder and filenameDo two quotes on the same day overwrite each other?

The first one is not code. It is writing down what actually appears on my quotes — item name, unit price, quantity, notes — before letting the agent invent fields. Transcribing three old quotes taught me something I had never noticed: every quote of mine carries a section listing what is not included, and that section is the longest one on the page.

That discovery changed the JSON shape. An exclusions field that I would have treated as an afterthought became a first-class array, which meant the template had room for it from the start. Had the agent designed the schema, it would have produced something reasonable and generic, and I would have spent the next three quotes bending my own paperwork to fit it.

Pulling the Arithmetic Out First

The second request covered calculation only. No formatting, no PDF.

# quote_calc.py — responsible for the arithmetic and nothing else
from decimal import Decimal, ROUND_DOWN
 
RATE = Decimal("0.10")  # the multiplier, decided in exactly one place
 
 
def line_total(unit_price: str, quantity: int) -> Decimal:
    """One line's subtotal. Build Decimal from a string, never from a float."""
    return Decimal(unit_price) * quantity
 
 
def summarize(items: list) -> dict:
    subtotal = sum((line_total(i["unit_price"], i["quantity"]) for i in items), Decimal("0"))
    surcharge = (subtotal * RATE).quantize(Decimal("1"), rounding=ROUND_DOWN)
    return {"subtotal": subtotal, "surcharge": surcharge, "total": subtotal + surcharge}
 
 
if __name__ == "__main__":
    items = [
        {"name": "Home page design", "unit_price": "120000", "quantity": 1},
        {"name": "Interior page design", "unit_price": "45000", "quantity": 3},
    ]
    print(summarize(items))
    # {'subtotal': Decimal('255000'), 'surcharge': Decimal('25500'), 'total': Decimal('280500')}

The one thing I spelled out in the prompt was that money must not be held as a float. Multiply 0.1 in binary floating point and the last digit starts drifting once the numbers get long. The first version used floats because I had said nothing about it. The numeric type is the caller's decision, not the agent's. Adding that single line to my prompts ended a repeated round of corrections.

Where the rounding happens is the second decision, and it is not cosmetic.

from decimal import Decimal
 
from quote_calc import summarize
 
 
def test_surcharge_is_rounded_once_on_the_subtotal():
    items = [
        {"name": "A", "unit_price": "335", "quantity": 1},
        {"name": "B", "unit_price": "335", "quantity": 1},
        {"name": "C", "unit_price": "335", "quantity": 1},
    ]
    result = summarize(items)
    assert result["subtotal"] == Decimal("1005")
    assert result["surcharge"] == Decimal("100")  # rounding per line gives 33 x 3 = 99, one yen short
    assert result["total"] == Decimal("1105")

Asking for this test before touching the implementation means that when the agent moves rounding somewhere else, something turns red immediately. Which convention you pick matters less than picking one and writing it down; a client who adds the lines by hand will land on whichever answer your document implies, and the two of you should not be reading different totals off the same page. The reason I could not chase that first stray yen was simply that nothing was in place to turn red.

Let HTML Carry the Layout, Put PDF on Top

Only the third request touched appearance. I knew I would nudge rule positions and logo margins many times, so I asked for the layout in HTML and CSS, with PDF rendering as a thin layer above it. Keeping something I can open in a browser and adjust suits how I actually work.

# render.py — responsible for appearance and nothing else
from pathlib import Path
 
from weasyprint import HTML
 
TEMPLATE = Path("template.html").read_text(encoding="utf-8")
 
 
def render_pdf(context: dict, out_path: Path) -> None:
    html = TEMPLATE
    for key, value in context.items():
        html = html.replace("{{" + key + "}}", str(value))
    HTML(string=html, base_url=".").write_pdf(out_path)

The template settles paper size, margins, and typeface up front.

@page { size: A4; margin: 18mm; }
body { font-family: "Noto Sans JP", "Hiragino Sans", "Yu Gothic", sans-serif; }
.amount { font-variant-numeric: tabular-nums; }

tabular-nums on the amount column is there so digits line up vertically. With figures stacked in a true column, a wrong order of magnitude stands out on a glance. For someone who once nearly sent a quote carrying last month's total, that is a checking device rather than a style preference.

Two Places It Went Wrong

Japanese text came out blank. The first PDF showed the Latin characters and left every Japanese line empty. The cause was the typeface: the font named in my CSS was not installed in that environment. Ask the agent about it and it will start rewriting the CSS, but the fix belongs on the environment side. Listing what is installed takes one command — fc-list :lang=ja family on Linux and most containers, fc-list or the Font Book app on macOS — and putting one of those names first in the stack makes the text appear on the next run. It is worth doing this before the first render rather than after, because a blank column reads like a template bug and sends you looking in the wrong file. Whether characters turn into garbage or disappear entirely points at different causes — a distinction I ran into before with garbled Japanese in the built-in terminal.

A page break landed in the middle of the item table. On a longer quote the table split across pages, and the continuation page carried numbers with no column headers. Wrapping the header row in thead and giving it display: table-header-group repeats the headers on every page. This one is invisible in the browser and only shows up in the PDF, so I keep a sample quote with about thirty rows and render that before I build a real one.

Deciding the Filename

The last request was the shortest of the four.

from datetime import date
 
 
def output_name(client: str, issued: date, revision: int) -> str:
    """A name that will not overwrite an earlier version made the same day."""
    safe = "".join(c if c.isalnum() else "_" for c in client).strip("_")
    return f"{issued:%Y%m%d}_{safe}_estimate_r{revision}.pdf"
 
 
if __name__ == "__main__":
    print(output_name("Yamada Design Studio", date(2026, 9, 19), 2))
    # 20260919_Yamada_Design_Studio_estimate_r2.pdf

The revision number is in there because a negotiation once left me unsure which figure I had actually sent. If nothing overwrites, the older file survives, and surviving files can be compared.

I keep the output folder outside the project directory as well. Early on I had the tool write into the repository, and a cleanup step removed a quote I still needed. Generated documents that a client has already seen are not build artifacts, even when a script produced them.

Splitting the work into four showed me the problem was never the agent's capability. The unit I was willing to call correct was simply too large. The single-shot version worked, in the sense that it ran. I just had no way to confirm it.

These days, before writing a request, I write one line first: what will I look at to decide whether the result is right? When I cannot write that line, the request is not yet small enough.

If you are moving similar paperwork off copy-and-edit, I'd start at step two — the calculation as its own function, with one test that agrees with your calculator. With that settled before the layout, every later adjustment costs far less.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-07-02
Stop Dreading the Rejection Email: Triaging App Store Review Feedback with an Antigravity Agent
A working setup for feeding App Store rejection notices into Antigravity, classifying them against a guideline playbook, and gating resubmission with an Info.plist lint — with real turnaround numbers.
App Dev2026-07-01
Systematize Pre-Release Checks for Sound-Playing Apps with Waveform Rendering and Agents
The 6/26 update let Antigravity render audio files. Drawing on years of shipping sound in healing apps, here is a design that folds loudness, clipping, length, and silence checks into an agent verification loop, with the actual scripts.
App Dev2026-06-24
Running Pre-Release Checks Without Opening the IDE — Designing the Android CLI as the Verification Gate of an Unattended Pipeline
How to slot Android CLI v1.0 into an unattended pipeline as its verification gate — three layers of checks, an exit-code contract, and a density-by-locale matrix, sized for an indie developer's day-to-day.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links