Price and quote a job
Two separate problems, usually confused. Pricing is working out what your work has to earn. Quoting is telling a customer a number and having them accept it. Getting the second right while the first is wrong is how a busy business goes broke, and it happens constantly, because the symptom of underpricing is more work, not less.
Ask these nine questions first
These nine are the inputs the rate model needs. Ask them all at once, and expect question 3 to be a guess, which is fine as long as everyone knows it is one.
1. Are you setting a charge out rate, or pricing one specific job?
2. Who is on the team, what do they earn, and what do you add for on costs?
3. Of the hours you pay for, how many are actually invoiced to a customer?
Is that measured, or estimated?
4. What are your annual overheads? Rent, vehicles, insurance, admin wages,
software, marketing, depreciation, accounting
5. What salary should the owner be on for the work they actually do?
6. What net margin do you want to make?
7. What do you mark materials up by?
8. What are you charging now?
9. Who are you competing against, and what do they charge?
Question 3 is load bearing, and the answer is almost always wrong the first time. If it is an estimate rather than a measurement, label it as one in the output, then measure it from the job system before anyone prices work on the result. The section below covers why it matters so much.
Question 5 is the one people do not think to answer. If the owner works in the business, their market rate is a cost whether or not it is drawn that way.
Do not ask for anything you can pull. Where an accounting system is connected, take the overheads from the profit and loss rather than asking somebody to list what they remember paying. What people remember and what the ledger says differ by more than they expect, almost always upward.
Question 9 is asked last on purpose. It is context for where the number lands, not an input to it.
The rate has to come from your numbers
Almost every small service business sets its rate by looking at the competitor down the road. That number contains none of your overhead, none of your billable percentage and none of your wage bill. It is a number from someone else’s business.
The arithmetic, done the long way round:
python scripts/charge_out_rate.py business.json
It takes your wages, on costs, overheads, owner’s salary and billable percentage, and returns what a billable hour actually costs you, the rate at which you make exactly nothing, and the rate that hits your target margin. Full method in references/cost-build-up.md, and a worked input file at assets/business.example.json.
Three things it will probably tell you, and they are the three that matter:
Your billable percentage is lower than you think. Most businesses assume 85% and run 65%. Travel, quoting, warranty returns, training, leave, waiting on site and chasing parts all live in the gap. Measure it from last year, do not assume it.
Overhead is fixed, so it spreads over whatever hours you actually bill. Losing five points of billable time does not cost you five percent. It costs the entire overhead recovery on those hours. The sensitivity table the script prints is the part worth showing an owner.
The owner’s salary belongs in the cost base, whether or not it is drawn that way. Leaving it out is how a business discovers it has been trading at a loss for three years while feeling busy.
Markup is not margin
The most common pricing error in service work, and it costs real money every single job.
| Markup on cost | Actual gross margin |
|---|---|
| 10% | 9.1% |
| 20% | 16.7% |
| 30% | 23.1% |
| 50% | 33.3% |
| 100% | 50.0% |
To get a 30% margin you mark up by 43%, not 30%. A business that quotes a 20% markup while budgeting a 20% margin is short 3.3 points on every dollar of materials, forever, and nobody ever finds it because both numbers say 20.
margin = (price - cost) / price
markup = (price - cost) / cost
price = cost / (1 - margin) to hit a target margin
Pricing the job, not the hour
Where you can honestly do it, quote a price for the outcome rather than a rate for your time.
- “$14,000 for the switchboard upgrade, tested and certified” is a decision the customer makes once
- “$180 an hour, roughly 70 hours” invites them to audit your timesheet, and caps your earnings at your own speed
Fixed pricing also rewards getting better at the work, which hourly billing actively punishes. The trade is that you now carry the estimating risk, so the estimate has to be built properly and the scope has to be tight.
Use hourly where the scope genuinely cannot be known: fault finding, investigation, works directed by someone else. Say so plainly and cap it. “Hourly at $180, capped at 8 hours, and we come back to you before exceeding it” is a fair offer that a fixed price cannot beat.
Estimating the job
- List the tasks. Not “install switchboard”. The actual steps, each with hours
- Price labour at your loaded rate, by the person who will actually do it. An apprentice hour and a senior hour are different costs and often different prices
- Price materials at cost, then mark up. Use the markup that produces your target margin
- Add the things everyone forgets: travel, site setup and pack down, waste removal, permits, certification, warranty allowance, the return visit for the thing that was not ready
- Add a contingency and say what it is for. 5% on work you have done a hundred times, 15% or more on anything unfamiliar or on someone else’s site
- Check the resulting margin. If it is below target, the price is wrong, not the target
Estimate from your own history, not from feel. Where ServiceM8 or an accounting system is connected, the actual hours and materials on comparable past jobs are the best estimating data that exists, and it is sitting there unused.
servicem8_list_jobs comparable jobs
servicem8_list_job_activities the hours actually worked, not the hours quoted
servicem8_list_job_materials what was actually consumed
xero_list_invoices what was actually billed
The gap between quoted hours and worked hours on past jobs is the single most valuable number in the business, and almost nobody looks at it.
The quote document
A quote is a small proposal. It has a job to do beyond stating a number: make it easy to say yes and safe to defend. Full detail in references/the-quote-document.md, template at assets/quote-template.md.
The parts that change the win rate:
- What is included, as deliverables. Not activities
- What is excluded. Three or four specific lines. The highest value paragraph in the document
- Options, usually three. A single price is a yes or no question. Three makes it “which one”
- An expiry date. Prices move, and an undated quote gets dug up in a year
- One clear next action
Rules
- Never discount because they paused. Silence is not an objection. A price that drops when someone hesitates teaches them the first number was invented, and they will pause longer next time
- If you must move, move the scope. Take something out, then lower the price. Never lower the price alone
- Quote promptly. For most service work the first credible quote wins a disproportionate share, and a quote that takes nine days signals how the job will run
- Price the awkward job higher, not lower. Difficult access, a difficult client, a tight deadline, unfamiliar work. The instinct to discount for a job you do not want is backwards
- Put the price in its own line, once. Splitting it across paragraphs or writing a range reads as discomfort, and the reader mirrors it
- Review your rate annually, on a date. Costs rise every year. Rates that rise only when it becomes unbearable rise in visible jumps, which customers notice far more than an annual adjustment
What not to do
- Do not quote off the top of your head on site. It is the single most expensive habit in trades and service work. Say “I will have that to you by Thursday”, and do
- Do not compete on price against someone whose costs you do not know. They may be cheaper because they are more efficient, or because they are about to go under. You cannot tell from outside, and matching them means betting your business on which it is
- Do not forget sales tax or GST. State clearly whether the price includes it. A quote that is ambiguous costs you the tax rate when the customer assumes it was included
- Do not use a materials markup as your whole profit. If labour is priced at cost and the profit comes from parts, every job with few parts is a loss and you will not know which ones
- Do not quote a job you do not want at a normal price. Sometimes they say yes
Producing the document
Write the quote as markdown and render it:
node scripts/render_pdf.mjs quote.md --brand=brand.json
A quote that arrives as a branded document wins against the same number in the body of an email, reliably enough that it is worth the two minutes. Needs Node 18 or newer and Chrome or Edge, and nothing installed. Copy assets/brand.example.json and change the values. Full detail in references/rendering-the-pdf.md.
Right align every price column, using a colon at the end of that column’s delimiter row.
Following up
A quote sent is not a quote answered. Most quotes that go quiet are not rejections, they are quotes nobody followed up, and follow up is where most of the value in quoting actually sits.
Say in the quote when you will follow up, then do it. A workable ladder:
| When | What | Tone |
|---|---|---|
| Day 2 | ”Did that arrive, and does it cover what you needed?” | Helpful. You are checking it landed, not chasing |
| Day 5 | A call. Not an email | Curious. Ask what they are weighing up |
| Day 10 | One thing of value: a photo of similar work, an availability slot, a question they raised | Useful, not needy |
| Day 20 | ”Still worth holding a slot, or should I close this off?” | Direct. Permission to say no |
| Expiry | ”Price expires Friday. Happy to re quote if the timing has changed” | Factual |
Two things matter more than the wording. The call at day 5 is the step that works, and it is the one people replace with a third email because it is easier. And giving them permission to say no at day 20 gets you a real answer, which is worth far more than an open quote sitting in a pipeline forever inflating your forecast.
Track sent, viewed, accepted and declined. A business that cannot state its win rate by job type cannot tell whether its price is wrong.
Reference files
Everything the skill tells your AI to read, exactly as it ships in the zip.
references/cost-build-up.md 6.2 KB
# What an hour actually costs
Run the script rather than doing this by hand, but understand what it is doing, because the inputs are where the number goes wrong.
```
python scripts/charge_out_rate.py business.json
```
## The four layers
```
1. Base wage what lands in the employee's account
2. + on costs what the employee actually costs you
3. / billable hours what a BILLABLE hour costs you
4. + overhead recovery what the business costs per billable hour
= break even
5. / (1 - target margin) the charge out rate
```
Most businesses stop after step 1, divide by 2,000 hours, double it, and call that a rate.
## Layer 1 and 2: the loaded cost of a person
The wage is not the cost. Add:
| On cost | Typical |
|---|---|
| Employer retirement contributions | Varies widely by jurisdiction |
| Workers compensation insurance | 1% to 8% depending on trade and claims history |
| Paid leave, sick leave, public holidays | Usually handled through billable hours instead, not here. Do not count it twice |
| Leave loading, where it applies | |
| Training, licences, tickets, renewals | |
| Protective equipment, uniforms, tools | |
| Phone, tablet, software seats | |
| Payroll tax, where the business is over the threshold | |
**Commonly 20% to 35% above the wage.** Guessing low here is the most common single error in the whole calculation, and it is invisible because it is spread across everything.
Vehicles are a judgement call. If one person drives one van, it is arguably an on cost for that person. If vans are shared, put them in overhead. Either is defensible. Doing both is not, and doing neither is the usual outcome.
## Layer 3: billable hours, the number that decides everything
Of the hours you pay for, what share is actually invoiced to a customer.
```
Paid hours per year 1,976 38 hours x 52 weeks
Annual leave -152
Public holidays -76
Sick leave, assumed -38
Training -40
Quoting and estimating -180
Travel between jobs -220
Warranty and rework -60
Admin, stock, vehicle -120
-------
Billable hours 1,090 55% of paid hours
```
That example is not pessimistic. It is what happens when someone measures rather than assumes.
**Measure it from last year.** Where jobs are tracked, the data is already there:
```
servicem8_list_job_activities hours logged against jobs
```
Compare total logged billable hours to total hours paid, per person. Most businesses assume 85% and discover 60% to 70%. An owner who also runs the business is frequently under 50%, which is exactly why their own labour has to be in the cost base and not treated as free.
**A rate built on 85% billable and delivered at 65% loses money on every hour**, and the loss is proportional to overhead, which is the part nobody watches.
## Layer 4: overhead recovery
Every cost that is not the direct labour on a job:
```
Rent and outgoings Admin and office wages
Vehicles, fuel, maintenance Software and subscriptions
Insurance Accounting and legal
Marketing Phone and internet
Tools and equipment Bank and merchant fees
Depreciation Bad debts
Training and memberships The owner's salary
```
**The owner's salary goes here** at a market rate for the work they do, whether or not it is drawn that way. This is not an accounting nicety. A business that omits it is measuring profit against a cost base that is missing a full time senior person, and it will conclude it is profitable when it is not.
Divide the total by total billable hours across the whole team. That is the overhead each billable hour has to carry.
**Where to get the real numbers**, rather than estimating:
```
xero_get_profit_and_loss last full year, by account
quickbooks_get_profit_and_loss
```
Take the actual operating expenses from the profit and loss rather than listing what you remember paying. What people remember and what the ledger says differ by more than they expect, almost always upward.
## Layer 5: the margin
Break even plus nothing is a cost, not a price. The charge out rate is:
```
rate = break even / (1 - target net margin)
```
Not break even multiplied by 1.12. That produces an 10.7% margin, not 12%, for the same reason a 20% markup is a 16.7% margin.
What margin to target is a business decision, not an arithmetic one. It has to cover: the profit the owner wants, tax on that profit, the cash the business needs to fund growth, and the jobs that go wrong. A business with no margin has no capacity to absorb a bad job, which is why thin margin businesses feel so fragile.
## The sensitivity table
The script prints what the rate has to be if billable hours land somewhere other than planned. It is the part worth putting in front of an owner, because it makes one thing concrete:
```
Change Billable hrs Break even Needed rate Profit at planned rate
-10% 6,029 131.84 149.82 18,065
0% 6,699 118.66 134.84 108,387
```
Ten percent fewer billable hours, at the same rate, took 83% of the profit. Overhead did not fall when the hours did.
This is why chasing utilisation is usually worth more than chasing revenue, and why a small improvement in quoting speed or travel time flows almost entirely to the bottom line.
## Different rates for different work
One blended rate is simpler and usually wrong at the edges. Consider separate rates where the cost genuinely differs:
| Situation | Why |
|---|---|
| Apprentice versus senior | Different loaded cost, different value delivered |
| After hours and weekends | Penalty rates change the loaded cost materially |
| Emergency callout | Different cost and much higher value at the moment of purchase |
| Contract or retainer work | Guaranteed volume and no acquisition cost justifies a lower rate |
| Travel beyond the usual radius | A real cost that is otherwise absorbed silently |
What not to do: invent a separate rate for a customer who complained. That is a discount with extra steps, and it spreads.
references/rendering-the-pdf.md 5.4 KB
# Rendering the document
`scripts/render_pdf.mjs` turns the markdown into a PDF with a cover page, a contents list, running headers, page numbers and tables that do not split a row across a page.
```
node scripts/render_pdf.mjs document.md
node scripts/render_pdf.mjs document.md --out=final.pdf --brand=brand.json
node scripts/render_pdf.mjs document.md --html
```
Needs Node 18 or newer and Chrome or Edge installed. There is no `npm install`: the markdown parser and the browser client are both inside the script.
`--html` stops after writing the intermediate HTML instead of rendering. Use it whenever the pagination is wrong: open that file in a browser and press print, and you see exactly what the renderer sees. Far faster than rendering a PDF to find out where a page broke.
## The cover page
Frontmatter at the very top of the file drives it. Leave it out and there is no cover, which is right for an internal draft and wrong for anything sent outside the business.
```
---
title: Quote
subtitle: Switchboard upgrade
client: Northside Electrical
author: Dana Reyes
date: March 2026
confidential: true
---
```
Keys used: `title`, `subtitle`, `client`, `author`, `company`, `date`, `version`, `confidential`. Anything else is parsed and ignored, which makes frontmatter a safe place for notes to yourself. Without a `title` there is no cover at all.
Only `key: value` on single lines. No nesting, no lists, no multi line values.
## Markers
| Marker | Effect |
|---|---|
| `<!-- toc -->` | A contents list built from the `##` and `###` headings |
| `<!-- pagebreak -->` | Forces a new page. `\newpage` on its own line is the same |
Every other HTML comment is stripped, so author notes in a template never reach the PDF.
## The brand file
One JSON file sets colours, fonts, logo, page size and margins. Copy `assets/brand.example.json`, change the values, and every document rendered with it matches.
The logo path inside the brand file resolves relative to the brand file, not to wherever you are standing, so a brand folder moves as one piece.
**Do not edit `assets/document.css` to change a colour.** Colours and fonts are injected from the brand file as CSS variables. Editing the stylesheet is how one customer's document ends up with another customer's blue. If one document must differ, pass `--css=just-this-one.css`, which is appended after the main stylesheet so you only write the difference.
## Supported markdown
A deliberate subset: headings, paragraphs, bold, italic, strikethrough, code spans and fenced blocks, ordered and unordered lists with one level of nesting, task list checkboxes, blockquotes, horizontal rules, links, images and pipe tables.
Anything outside the subset falls through as a paragraph rather than failing, so a document never silently loses text.
**Not supported:** reference style links, inline HTML (escaped on purpose, so a document assembled partly from a customer's own words cannot inject markup), footnotes, nesting past one level, hard line breaks from trailing spaces, bare URL autolinks, and syntax highlighting in code fences.
## Table alignment, which matters most here
The delimiter row sets it, and a financial document lives or dies on this:
```
| Line | FY26 | FY27 |
|---------------|----------:|----------:|
| Revenue | 3,400,000 | 4,100,000 |
| Gross profit | 1,054,000 | 1,435,000 |
```
A colon at the end of a column's delimiter right aligns it. Right aligned cells also get tabular figures, so the digits line up in a column even in a proportional font. **Right align every column of numbers.** A column of currency that is left aligned reads as amateur from across a room.
## When it comes out wrong
| Problem | Cause |
|---|---|
| "No Chrome or Edge found" | The error lists every path it tried. Add yours to the `BROWSERS` array at the top of the script |
| Images are blank boxes | Image paths resolve relative to the markdown file, logo paths relative to the brand file. A very large image can also exceed the 15 second readiness ceiling: shrink it |
| Mysterious blank page | A pagebreak immediately before a heading that already starts a page, or a trailing pagebreak at the end of the document |
| The margins doubled | An `@page` rule was added to the stylesheet. Chrome applies it on top of the print call's margins. Remove it and set margins in the brand file |
| Everything on one enormous page | Something has a fixed height larger than the page, usually a pasted element with an inline style |
| A table row split across a page | A single row taller than a page cannot avoid breaking. Shorten the cell. A long table splitting is correct, and the header repeats automatically |
| Numbers do not line up | The column is not right aligned |
| Text where markup should be | The parser is a subset. See above |
Exit codes: `0` rendered, `1` usage or input error, `2` the browser failed to start or the render failed. On `2` the intermediate HTML is deliberately left on disk and its path is printed. Open it.
## Before sending
- **Read the PDF, not the markdown.** The PDF is the artefact, and reading the source and assuming is how a broken table reaches a bank
- **Check the last page.** A single orphaned line on a final page is the most common flaw in a generated document, and the fix is usually cutting two sentences rather than changing the CSS
- **Name the file the way the recipient will file it.** `northside-switchboard-quote-2026-03.pdf`, not `document.pdf` or `final_v3_FINAL.pdf`
references/the-quote-document.md 5.7 KB
# The quote document
A quote is a small proposal. Its job is not to state a number, it is to make saying yes easy and safe.
## What goes in it
| Part | Why it is there |
|---|---|
| What we will do | Deliverables, not activities |
| What is not included | The paragraph that prevents the argument later |
| The price | Once, plainly, in its own line |
| What it assumes | The numeric assumptions especially |
| Timing | From acceptance, not calendar dates |
| Payment terms | Deposit, balance, terms, what happens on late payment |
| Validity | An expiry date. Prices move |
| Next step | One action |
## Detail level
Enough that the customer can see what they are buying. Not so much that they can price shop it line by line.
**Too little:**
```
Switchboard upgrade $14,000
```
They cannot tell what they are getting, so they get another quote to find out, and now you are in a price comparison you did not choose.
**Too much:**
```
Circuit breaker, 32A, C curve x14 @ $47.20 $660.80
Labour, senior electrician 8.5h @ $180.00 $1,530.00
```
Now every line is negotiable, they can price the breakers online, and the conversation is about your markup instead of about the job.
**About right:**
```
Supply and install new 24 pole distribution board $8,400
Includes all breakers, RCDs, labelling and testing
Decommission and remove existing board $1,200
Certification and compliance documentation $900
Making good to the immediate wall penetration $500
------------
Total, excluding tax $11,000
```
Grouped by outcome. Each line is something the customer recognises as a thing they want. Nothing is individually shoppable.
## Options
Three, where the work allows it. A single price is a yes or no question. Three options changes it to "which one", which is a much easier conversation to be in and produces a higher average sale even when most people pick the middle.
```
Essential The board replaced to code $11,000
Recommended The above, plus surge protection and $14,400
a 12 month inspection
Complete The above, plus the sub board and $19,800
5 year scheduled maintenance
```
Build them so the middle one is the one you expect and want them to take. The cheapest exists to make the middle look reasonable and to be a real option for someone who genuinely cannot spend more. The top one exists because a meaningful minority always takes it, and because it makes the middle look moderate.
**Do not make the cheapest option a bad job.** An option that will not serve them is not an option, it is a trap, and it will come back as a warranty claim or a review.
## Exclusions
Three to six specific lines, written from your own scar tissue rather than from a generic list.
```
Not included
Any repair to existing wiring found to be non compliant
Asbestos identification or removal
Making good to paint or wallpaper
Out of hours attendance, unless requested at the after hours rate
Council or utility fees, charged at cost
```
Every one of those is an invoice conversation that now does not have to happen mid job with a customer who feels ambushed.
## Assumptions
The numeric ones prevent the worst surprises:
```
This price assumes
The existing board is accessible without removing cabinetry
Power can be isolated for up to 4 hours during business hours
No more than 24 circuits requiring transfer
```
If the third turns out to be 31 circuits, that is a variation conversation rather than a dispute, because it was named in advance.
## Validity and terms
```
This quote is valid for 30 days from the date above.
Deposit of 30% on acceptance, balance on completion, terms 14 days.
Prices exclude sales tax.
```
**Say whether tax is included.** Ambiguity here costs you the tax rate, because the customer assumes the number they saw is the number they pay.
**Put an expiry on it.** Material prices move, and without a date a quote gets produced a year later and you either honour it or start the relationship with a refusal.
## The next step
One action, stated plainly:
```
Reply to this email with "approved" and we will book the work within
five working days. If you would rather talk it through first, call me
on <number> any weekday.
```
If accepting requires a signature, put the block in the document. Every extra step between yes and started loses a percentage, and the percentage is bigger than anyone expects.
## Sending it
Where the job lives in a system, raise the quote there rather than in a separate document. The customer gets a consistent record, the acceptance is captured, and the accepted quote converts to an invoice without being retyped.
```
servicem8_produce_document quote from the job, using your template
xero_list_quotes quotes raised, and their status
quickbooks_list_estimates
```
**Track sent, viewed, accepted and declined.** A business that cannot say its win rate cannot tell whether its price is wrong, and win rate by job type is more useful than the overall number.
## After sending
A quote sent is not a quote answered. The majority of quotes that go quiet were never followed up, and follow up is where most of the value in quoting actually is.
Say in the quote when you will follow up, then do it. A ladder that works: a "did that arrive" check on day 2, a phone call on day 5, something of value on day 10, and a "should I close this off" on day 20 that gives them permission to say no.
The call on day 5 is the step that works and the one people replace with a third email because it is easier.
scripts/charge_out_rate.py 11.2 KB
#!/usr/bin/env python3
"""
Work out what an hour actually costs you, and what you have to charge for it.
python charge_out_rate.py business.json
Most service businesses set their rate by looking at what the competitor down the
road charges. That number contains none of your overhead, none of your actual
billable percentage, and none of your wage bill. This does the arithmetic the
long way round, which is the only way it comes out right.
It prints four things:
cost what a billable hour costs you, loaded and with overhead in it
breakeven the rate at which you make exactly nothing
rate the rate that hits your target net margin
sensitivity what happens to all of it when billable hours move
No dependencies. Output is a table by default, or JSON with --json.
Input format, all JSON:
{
"name": "Northside Electrical",
"period": "FY26",
"team": [
{"role": "Senior technician", "count": 2, "wage": 82000,
"on_cost_pct": 26, "hours_paid": 1976, "billable_pct": 70},
{"role": "Apprentice", "count": 1, "wage": 41000,
"on_cost_pct": 24, "hours_paid": 1976, "billable_pct": 55}
],
"overheads": {
"rent": 42000, "vehicles": 58000, "insurance": 18000,
"software": 9000, "admin wages": 95000, "other": 24000
},
"owner_salary": 120000,
"target_net_margin_pct": 12,
"materials_markup_pct": 20
}
Field notes, because these are where the number goes wrong:
wage base wage per person per year, before on costs
on_cost_pct employer contributions, insurance, leave loading, training,
protective equipment. Commonly 20 to 35 percent depending on
jurisdiction. Guessing low here is the most common error in
this whole calculation
hours_paid hours you PAY for in a year, including leave and public
holidays. 1976 is 38 hours x 52. Do not net leave out here,
net it out through billable_pct instead
billable_pct of paid hours, the share actually invoiced to a customer.
Travel, quoting, warranty returns, training, leave, admin and
waiting on site all live in the gap. Measure it from last year
rather than assuming. Most businesses guess 85 and run 65
owner_salary a market rate salary for the owner's own labour, whether or
not it is drawn that way. Leaving it out is how a business
discovers it has been trading at a loss for three years
"""
import json
import sys
MONEY = "{:>12,.0f}"
RATE = "{:>12,.2f}"
def money(v):
return MONEY.format(v)
def die(msg):
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def load(path):
try:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
except FileNotFoundError:
die(f"no such file: {path}")
except json.JSONDecodeError as e:
die(f"{path} is not valid JSON: {e}")
def compute(spec):
warnings = []
team = spec.get("team") or []
if not team:
die("no team members. Add at least one entry to \"team\".")
rows = []
direct_cost = 0.0
billable_hours = 0.0
for m in team:
role = m.get("role", "unnamed")
count = float(m.get("count", 1))
wage = float(m.get("wage", 0))
on_cost = float(m.get("on_cost_pct", 0))
hours_paid = float(m.get("hours_paid", 1976))
billable_pct = float(m.get("billable_pct", 0))
if wage <= 0:
die(f"{role}: wage must be above zero.")
if not 0 < billable_pct <= 100:
die(f"{role}: billable_pct must be between 0 and 100, got {billable_pct}.")
if on_cost < 10:
warnings.append(
f"{role}: on_cost_pct of {on_cost:.0f}% is low. Employer "
"contributions, leave, insurance and equipment usually land "
"between 20 and 35 percent.")
if billable_pct > 85:
warnings.append(
f"{role}: billable_pct of {billable_pct:.0f}% is very high. "
"Travel, quoting, warranty, training and leave all sit in the "
"gap. Measure it before relying on it.")
loaded = wage * (1 + on_cost / 100) * count
hours = hours_paid * (billable_pct / 100) * count
direct_cost += loaded
billable_hours += hours
rows.append({
"role": role, "count": count, "loaded_cost": loaded,
"billable_hours": hours,
"cost_per_billable_hour": loaded / hours if hours else 0.0,
})
if billable_hours <= 0:
die("total billable hours came to zero.")
overheads = spec.get("overheads") or {}
overhead_total = sum(float(v) for v in overheads.values())
owner_salary = float(spec.get("owner_salary", 0))
if owner_salary <= 0:
warnings.append(
"owner_salary is zero. If the owner works in the business, their "
"market rate belongs in the cost base. Leaving it out is how a "
"business discovers it has been trading at a loss for three years.")
overhead_total += owner_salary
margin = float(spec.get("target_net_margin_pct", 0))
if margin >= 100:
die("target_net_margin_pct must be below 100.")
if margin <= 0:
warnings.append("target_net_margin_pct is zero, so the rate returned is "
"break even. That is a cost, not a price.")
direct_per_hour = direct_cost / billable_hours
overhead_per_hour = overhead_total / billable_hours
breakeven = direct_per_hour + overhead_per_hour
rate = breakeven / (1 - margin / 100) if margin < 100 else breakeven
revenue = rate * billable_hours
gross_profit = revenue - direct_cost
gross_margin = (gross_profit / revenue * 100) if revenue else 0.0
return {
"rows": rows,
"direct_cost": direct_cost,
"overhead_total": overhead_total,
"overhead_breakdown": {**{k: float(v) for k, v in overheads.items()},
**({"owner salary": owner_salary} if owner_salary else {})},
"billable_hours": billable_hours,
"direct_per_hour": direct_per_hour,
"overhead_per_hour": overhead_per_hour,
"breakeven_rate": breakeven,
"target_net_margin_pct": margin,
"charge_out_rate": rate,
"revenue_at_rate": revenue,
"gross_profit": gross_profit,
"gross_margin_pct": gross_margin,
"net_profit": revenue - direct_cost - overhead_total,
"materials_markup_pct": float(spec.get("materials_markup_pct", 0)),
"warnings": warnings,
}
def sensitivity(spec, result):
"""
What the rate has to be if billable hours land somewhere other than planned.
This is the table that changes minds. Overhead is fixed, so it spreads across
whatever hours you actually bill. Losing five points of billable time does not
cost you five percent, it costs you the whole overhead recovery on those hours.
"""
out = []
base = result["billable_hours"]
margin = result["target_net_margin_pct"]
for shift in (-20, -15, -10, -5, 0, 5, 10):
hours = base * (1 + shift / 100)
if hours <= 0:
continue
direct = result["direct_cost"] / hours
overhead = result["overhead_total"] / hours
be = direct + overhead
rate = be / (1 - margin / 100) if margin < 100 else be
out.append({
"shift_pct": shift, "billable_hours": hours,
"breakeven_rate": be, "charge_out_rate": rate,
"profit_at_planned_rate":
result["charge_out_rate"] * hours - result["direct_cost"] - result["overhead_total"],
})
return out
def report(spec, r, sens):
name = spec.get("name", "This business")
period = spec.get("period", "")
title = f"{name} {period}".strip()
print(f"\n{title}")
print("=" * max(len(title), 64))
print("\nLABOUR")
print(f" {'Role':<26}{'People':>8}{'Loaded cost':>14}{'Billable hrs':>14}{'Cost/hr':>12}")
for row in r["rows"]:
print(f" {row['role'][:26]:<26}{row['count']:>8,.0f}"
f"{row['loaded_cost']:>14,.0f}{row['billable_hours']:>14,.0f}"
f"{row['cost_per_billable_hour']:>12,.2f}")
print(f" {'TOTAL':<26}{'':>8}{r['direct_cost']:>14,.0f}{r['billable_hours']:>14,.0f}"
f"{r['direct_per_hour']:>12,.2f}")
print("\nOVERHEAD")
for k, v in sorted(r["overhead_breakdown"].items(), key=lambda kv: -kv[1]):
print(f" {k[:40]:<40}{money(v)}")
print(f" {'TOTAL':<40}{money(r['overhead_total'])}")
print(f" {'per billable hour':<40}{RATE.format(r['overhead_per_hour'])}")
print("\nTHE RATE")
print(f" {'Direct labour, per billable hour':<40}{RATE.format(r['direct_per_hour'])}")
print(f" {'Overhead, per billable hour':<40}{RATE.format(r['overhead_per_hour'])}")
print(f" {'BREAK EVEN, per billable hour':<40}{RATE.format(r['breakeven_rate'])}")
print(f" {'Target net margin':<40}{r['target_net_margin_pct']:>11,.1f}%")
print(f" {'CHARGE OUT RATE':<40}{RATE.format(r['charge_out_rate'])}")
print("\nAT THAT RATE, FOR THE PERIOD")
print(f" {'Revenue from labour':<40}{money(r['revenue_at_rate'])}")
print(f" {'Gross profit':<40}{money(r['gross_profit'])}")
print(f" {'Gross margin':<40}{r['gross_margin_pct']:>11,.1f}%")
print(f" {'Net profit':<40}{money(r['net_profit'])}")
if r["materials_markup_pct"]:
mk = r["materials_markup_pct"]
gm = mk / (100 + mk) * 100
print(f"\nMATERIALS")
print(f" {'Markup on cost':<40}{mk:>11,.1f}%")
print(f" {'which is a gross margin of':<40}{gm:>11,.1f}%")
print(" Markup and margin are not the same number. A 20% markup is a")
print(" 16.7% margin. Quoting one while budgeting the other is the")
print(" most common pricing error in service work.")
print("\nIF BILLABLE HOURS MOVE")
print(f" {'Change':>8}{'Billable hrs':>14}{'Break even':>13}{'Needed rate':>13}"
f"{'Profit at planned rate':>24}")
for s in sens:
mark = " <-- plan" if s["shift_pct"] == 0 else ""
print(f" {s['shift_pct']:>7}%{s['billable_hours']:>14,.0f}"
f"{s['breakeven_rate']:>13,.2f}{s['charge_out_rate']:>13,.2f}"
f"{s['profit_at_planned_rate']:>24,.0f}{mark}")
print("\n Overhead is fixed, so it spreads over whatever hours you actually")
print(" bill. Five points of lost billable time does not cost five percent,")
print(" it costs the entire overhead recovery on those hours.")
if r["warnings"]:
print("\nWARNINGS")
for w in r["warnings"]:
print(f" - {w}")
print()
def main():
args = [a for a in sys.argv[1:]]
as_json = "--json" in args
paths = [a for a in args if not a.startswith("--")]
if not paths:
print(__doc__.strip().split("\n\n")[1])
print("\nUsage: python charge_out_rate.py <business.json> [--json]")
sys.exit(1)
spec = load(paths[0])
result = compute(spec)
sens = sensitivity(spec, result)
if as_json:
print(json.dumps({"result": result, "sensitivity": sens}, indent=2))
else:
report(spec, result, sens)
if __name__ == "__main__":
main()
scripts/render_pdf.mjs 20.4 KB
/**
* Render a markdown document to a branded, print-quality PDF.
*
* node render_pdf.mjs plan.md
* node render_pdf.mjs plan.md --out=business-plan.pdf --brand=brand.json
* node render_pdf.mjs plan.md --html keep the intermediate HTML and stop
*
* Needs Node 18+ and Chrome or Edge installed. No npm install: the markdown parser
* and the CDP client below are the whole dependency list.
*
* Why Chrome and not a PDF library: a real business document needs a cover page,
* running headers and footers, page numbers, controlled page breaks and tables that
* do not split a row across a page. Chrome's print engine does all of that from CSS
* you can read, and the output is the same shape a designer would expect. A PDF
* library would mean drawing every box by hand.
*
* The intermediate HTML is written NEXT TO the source markdown, not in a temp
* directory, so relative image paths in the markdown resolve the way the author
* wrote them. It is deleted afterwards unless --html is passed.
*
* Frontmatter (optional, must be the very first thing in the file) drives the cover:
*
* ---
* title: Business plan
* subtitle: FY26 to FY28
* client: Northside Electrical
* author: Dana Reyes
* date: March 2026
* confidential: true
* ---
*
* Body markers:
* <!-- toc --> replaced by a contents list built from the h2 and h3 headings
* <!-- pagebreak --> forces a new page. \newpage on its own line does the same
*
* Exit codes: 0 rendered, 1 usage or input error, 2 browser or render failure.
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const srcArg = argv.find((a) => !a.startsWith("--"));
const flag = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const htmlOnly = argv.includes("--html");
if (!srcArg) {
console.error("Usage: node render_pdf.mjs <document.md> [--out=file.pdf] [--brand=brand.json] [--css=extra.css] [--html]");
process.exit(1);
}
const srcPath = resolve(srcArg);
if (!existsSync(srcPath)) {
console.error(`No such file: ${srcPath}`);
process.exit(1);
}
const srcDir = dirname(srcPath);
const outPath = resolve(flag("out") ?? join(srcDir, `${basename(srcPath, extname(srcPath))}.pdf`));
const BROWSERS = [
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
const DEFAULT_BRAND = {
name: "",
accent: "#0B6EE8",
ink: "#12161C",
muted: "#5A6472",
rule: "#DCE1E8",
headingFont: "Georgia, 'Times New Roman', serif",
bodyFont: "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
monoFont: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
logo: null,
footerNote: "",
pageSize: "A4",
margin: { top: "22mm", bottom: "20mm", left: "18mm", right: "18mm" },
};
/* ------------------------------------------------------------------ markdown */
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
/** Inline spans. Code first, so a backtick span is never re-parsed for emphasis. */
function inline(text) {
const code = [];
let s = text.replace(/`([^`]+)`/g, (_, c) => `\u0000${code.push(`<code>${esc(c)}</code>`) - 1}\u0000`);
// Author notes are not content. Code spans were lifted out above, so a comment
// deliberately being SHOWN inside backticks survives this.
s = s.replace(/<!--[\s\S]*?-->/g, "");
s = esc(s);
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
(_, alt, src, title) => `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ""}>`);
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
return s;
}
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
/** Pull `key: value` frontmatter if the file opens with a --- fence. */
function frontmatter(raw) {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!m) return { meta: {}, body: raw };
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
}
return { meta, body: raw.slice(m[0].length) };
}
/**
* Block parser. A deliberate subset: headings, paragraphs, fenced code, pipe tables,
* ordered and unordered lists with one level of nesting, blockquotes, rules, images.
* Anything outside the subset passes through as a paragraph rather than failing, so a
* document never silently loses text.
*/
function blocks(md) {
const lines = md.replace(/\r\n/g, "\n").split("\n");
const out = [];
const headings = [];
let i = 0;
/** A sublist belongs INSIDE the <li> it hangs off, not after it. */
const nest = (item, sub) => item.replace(/<\/li>$/, `<ul>${sub.join("")}</ul></li>`);
const listItem = (text) => {
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
if (!task) return `<li>${inline(text)}</li>`;
const done = task[1].toLowerCase() === "x";
return `<li class="task"><span class="box${done ? " on" : ""}"></span>${inline(task[2])}</li>`;
};
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
if (/^(<!--\s*pagebreak\s*-->|\\newpage)\s*$/i.test(line.trim())) {
out.push('<div class="pagebreak"></div>'); i++; continue;
}
if (/^<!--\s*toc\s*-->$/i.test(line.trim())) { out.push("\u0001TOC\u0001"); i++; continue; }
// An author note, possibly spanning several lines. Templates are full of these
// and none of them belong in the rendered document.
if (line.trimStart().startsWith("<!--")) {
while (i < lines.length && !lines[i].includes("-->")) i++;
i++;
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
const level = h[1].length;
const text = h[2].trim();
const id = slug(text);
if (level === 2 || level === 3) headings.push({ level, text, id });
out.push(`<h${level} id="${id}">${inline(text)}</h${level}>`);
i++; continue;
}
const fence = line.match(/^```\s*([\w-]*)\s*$/);
if (fence) {
const buf = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(`<pre class="code"><code>${esc(buf.join("\n"))}</code></pre>`);
continue;
}
if (/^(\*\*\*|---|___)\s*$/.test(line.trim())) { out.push("<hr>"); i++; continue; }
// Pipe table. Needs the delimiter row, otherwise it is just text with pipes in it.
if (line.includes("|") && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1] ?? "")) {
const cells = (r) => r.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
const head = cells(line);
const align = cells(lines[i + 1]).map((d) =>
d.startsWith(":") && d.endsWith(":") ? "center" : d.endsWith(":") ? "right" : "left");
i += 2;
const body = [];
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) body.push(cells(lines[i++]));
const th = head.map((c, n) => `<th style="text-align:${align[n] ?? "left"}">${inline(c)}</th>`).join("");
const tr = body.map((r) =>
`<tr>${r.map((c, n) => `<td style="text-align:${align[n] ?? "left"}">${inline(c)}</td>`).join("")}</tr>`).join("");
out.push(`<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`);
continue;
}
if (/^\s*>/.test(line)) {
const buf = [];
while (i < lines.length && /^\s*>/.test(lines[i])) buf.push(lines[i++].replace(/^\s*>\s?/, ""));
out.push(`<blockquote>${blocks(buf.join("\n")).html}</blockquote>`);
continue;
}
const bullet = line.match(/^(\s*)([-*+])\s+(.*)$/);
const number = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
if (bullet || number) {
const ordered = Boolean(number);
const tag = ordered ? "ol" : "ul";
const start = ordered ? Number(number[2]) : 1;
const items = [];
let nested = null;
while (i < lines.length) {
const b = lines[i].match(/^(\s*)([-*+])\s+(.*)$/);
const n = lines[i].match(/^(\s*)(\d+)[.)]\s+(.*)$/);
const m = b || n;
if (!m) {
// A plain indented line continues the item it follows.
if (items.length && /^\s{2,}\S/.test(lines[i])) { items[items.length - 1] += ` ${inline(lines[i].trim())}`; i++; continue; }
break;
}
if (Boolean(n) !== ordered && m[1].length === 0) break;
if (m[1].length >= 2) {
nested ??= [];
nested.push(listItem(m[3]));
i++; continue;
}
if (nested) { items[items.length - 1] = nest(items[items.length - 1], nested); nested = null; }
items.push(listItem(m[3]));
i++;
}
if (nested && items.length) items[items.length - 1] = nest(items[items.length - 1], nested);
out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ""}>${items.join("")}</${tag}>`);
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*>|\s*[-*+]\s|\s*\d+[.)]\s)/.test(lines[i])) {
para.push(lines[i++]);
}
const joined = para.join(" ").trim();
if (!joined) { i++; continue; }
// A paragraph that is nothing but an image gets to be a figure, not a text line.
const lone = joined.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/);
out.push(lone
? `<figure><img src="${lone[2]}" alt="${lone[1]}">${lone[1] ? `<figcaption>${inline(lone[1])}</figcaption>` : ""}</figure>`
: `<p>${inline(joined)}</p>`);
}
return { html: out.join("\n"), headings };
}
/* ---------------------------------------------------------------- assembling */
function coverPage(meta, brand) {
if (!meta.title) return "";
const logo = brand.logo ? `<img class="cover-logo" src="${brand.logo}" alt="">` : "";
const rows = [
meta.client && ["Prepared for", meta.client],
meta.author && ["Prepared by", meta.author],
(meta.company || brand.name) && ["Company", meta.company || brand.name],
meta.date && ["Date", meta.date],
meta.version && ["Version", meta.version],
].filter(Boolean);
return `<section class="cover">
${logo}
<div class="cover-body">
<h1 class="cover-title">${inline(meta.title)}</h1>
${meta.subtitle ? `<p class="cover-subtitle">${inline(meta.subtitle)}</p>` : ""}
${rows.length ? `<dl class="cover-meta">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${inline(v)}</dd>`).join("")}</dl>` : ""}
${String(meta.confidential).toLowerCase() === "true" ? '<p class="cover-confidential">Commercial in confidence</p>' : ""}
</div>
</section>
<div class="pagebreak"></div>`;
}
/**
* A contents list, without page numbers. Page numbers would need a second render to
* find out what page each heading landed on, and a contents list that is confidently
* wrong is worse than one that is honestly a list of sections.
*/
function toc(headings) {
if (!headings.length) return "";
const items = headings.map((h) =>
`<li class="toc-h${h.level}"><a href="#${h.id}">${inline(h.text)}</a></li>`).join("");
return `<nav class="toc"><h2 class="toc-title">Contents</h2><ol>${items}</ol></nav>`;
}
async function loadBrand() {
const path = flag("brand");
if (!path) return DEFAULT_BRAND;
let parsed;
try {
parsed = JSON.parse(await readFile(resolve(path), "utf8"));
} catch (e) {
console.error(`Could not read brand file ${path}: ${e.message}`);
process.exit(1);
}
const brand = { ...DEFAULT_BRAND, ...parsed, margin: { ...DEFAULT_BRAND.margin, ...(parsed.margin ?? {}) } };
// A logo path in the brand file is relative to the brand file, not the cwd.
if (brand.logo && !/^(https?:|data:)/.test(brand.logo)) {
brand.logo = pathToFileURL(resolve(dirname(resolve(path)), brand.logo)).href;
}
return brand;
}
async function readCss(brand) {
const bundled = join(HERE, "..", "assets", "document.css");
let css = existsSync(bundled) ? await readFile(bundled, "utf8") : "";
if (!css) console.warn("assets/document.css not found next to the script. Rendering with browser defaults.");
const extra = flag("css");
if (extra) css += `\n${await readFile(resolve(extra), "utf8")}`;
const vars = `:root{
--accent:${brand.accent};--ink:${brand.ink};--muted:${brand.muted};--rule:${brand.rule};
--font-heading:${brand.headingFont};--font-body:${brand.bodyFont};--font-mono:${brand.monoFont};
}`;
return `${vars}\n${css}`;
}
/* ------------------------------------------------------------------- browser */
class Cdp {
#ws; #id = 0; #pending = new Map();
static async connect(url) {
const c = new Cdp();
c.#ws = new WebSocket(url);
await new Promise((res, rej) => {
c.#ws.onopen = res;
c.#ws.onerror = () => rej(new Error("CDP websocket failed to open"));
});
c.#ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
const p = c.#pending.get(msg.id);
if (!p) return; // an event, not a reply
c.#pending.delete(msg.id);
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
};
return c;
}
send(method, params = {}, sessionId) {
const id = ++this.#id;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
this.#ws.send(JSON.stringify({ id, method, params, sessionId }));
});
}
close() { try { this.#ws.close(); } catch { /* already gone */ } }
}
async function launch(port) {
const exe = BROWSERS.find((p) => existsSync(p));
if (!exe) throw new Error(`No Chrome or Edge found. Looked in:\n ${BROWSERS.join("\n ")}`);
const profile = await mkdtemp(join(tmpdir(), "render-pdf-"));
const proc = spawn(exe, [
"--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
"--no-first-run", "--no-default-browser-check", "--disable-extensions",
"--force-color-profile=srgb", "--allow-file-access-from-files", "about:blank",
]);
proc.on("error", (e) => { console.error("Failed to launch browser:", e.message); process.exit(2); });
const deadline = Date.now() + 20000;
let wsUrl;
while (Date.now() < deadline && !wsUrl) {
try {
const res = await fetch(`http://127.0.0.1:${port}/json/version`);
if (res.ok) wsUrl = (await res.json()).webSocketDebuggerUrl;
} catch { /* not up yet */ }
if (!wsUrl) await new Promise((r) => setTimeout(r, 120));
}
if (!wsUrl) throw new Error(`DevTools never came up on :${port}`);
const cdp = await Cdp.connect(wsUrl);
return {
cdp,
async dispose() { cdp.close(); proc.kill(); await rm(profile, { recursive: true, force: true }).catch(() => {}); },
};
}
const PAGE_SIZES = { // inches, because printToPDF takes inches
A4: [8.27, 11.69],
Letter: [8.5, 11],
Legal: [8.5, 14],
A5: [5.83, 8.27],
};
const mmToIn = (v) => {
const n = parseFloat(v);
if (/mm$/i.test(v)) return n / 25.4;
if (/cm$/i.test(v)) return n / 2.54;
if (/in$/i.test(v)) return n;
if (/pt$/i.test(v)) return n / 72;
return n / 25.4; // bare numbers are millimetres
};
/* ---------------------------------------------------------------------- main */
const raw = await readFile(srcPath, "utf8");
const { meta, body } = frontmatter(raw);
const brand = await loadBrand();
const parsed = blocks(body);
const html = parsed.html.replace("\u0001TOC\u0001", toc(parsed.headings));
const css = await readCss(brand);
const docTitle = meta.title || basename(srcPath, extname(srcPath));
const page = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${esc(docTitle)}</title>
<style>${css}</style></head>
<body class="doc">
${coverPage(meta, brand)}
<main>${html}</main>
</body></html>`;
const htmlPath = join(srcDir, `.${basename(srcPath, extname(srcPath))}.render.html`);
await writeFile(htmlPath, page, "utf8");
if (htmlOnly) {
console.log(`HTML written to ${htmlPath}`);
console.log("Open it in a browser and use Print to preview pagination before rendering the PDF.");
process.exit(0);
}
const [pw, ph] = PAGE_SIZES[brand.pageSize] ?? PAGE_SIZES.A4;
// The header and footer are separate mini-documents with no access to the page CSS,
// so their styling is inline and their font sizes are absolute. This is a Chrome rule,
// not a choice.
const chrome = (content) =>
`<div style="width:100%;font-size:8px;font-family:${brand.bodyFont.replace(/"/g, "'")};color:${brand.muted};
padding:0 ${brand.margin.left} 0 ${brand.margin.right};display:flex;justify-content:space-between;">${content}</div>`;
let browser;
try {
browser = await launch(9339);
const { cdp } = browser;
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Runtime.enable", {}, sessionId);
await cdp.send("Page.navigate", { url: pathToFileURL(htmlPath).href }, sessionId);
// The Cdp client above drops events on the floor, so readiness is polled rather than
// awaited. Poll for the real condition: the document is complete AND every image has
// finished, because a half decoded image prints as a blank box with no error anywhere.
// `complete` goes true when an image finishes loading OR fails. Waiting for
// naturalWidth as well would hang the full 15s on every broken path, which is the
// opposite of useful: a missing logo should be reported instantly, not waited on.
const ready = `document.readyState === "complete" &&
Array.from(document.images).every((i) => i.complete)`;
const deadline = Date.now() + 15000;
for (;;) {
const { result } = await cdp.send("Runtime.evaluate", { expression: ready, returnByValue: true }, sessionId);
if (result.value === true) break;
if (Date.now() > deadline) { console.warn("Page never settled after 15s. Printing it as it stands."); break; }
await new Promise((r) => setTimeout(r, 100));
}
// Name anything that failed to load. A blank box in a PDF with no warning anywhere
// is the single most common way a branded document goes out looking broken.
const { result: broken } = await cdp.send("Runtime.evaluate", {
expression: `JSON.stringify(Array.from(document.images)
.filter((i) => i.naturalWidth === 0 && i.getAttribute("src"))
.map((i) => i.getAttribute("src")))`,
returnByValue: true,
}, sessionId);
for (const src of JSON.parse(broken.value || "[]")) {
console.warn(`Image did not load, it will be blank in the PDF: ${decodeURI(src)}`);
}
// Fonts lay out after load and shift the pagination if printed too early.
await cdp.send("Runtime.evaluate", { expression: "document.fonts.ready", awaitPromise: true }, sessionId).catch(() => {});
const { data } = await cdp.send("Page.printToPDF", {
printBackground: true,
preferCSSPageSize: false,
paperWidth: pw,
paperHeight: ph,
marginTop: mmToIn(brand.margin.top),
marginBottom: mmToIn(brand.margin.bottom),
marginLeft: mmToIn(brand.margin.left),
marginRight: mmToIn(brand.margin.right),
displayHeaderFooter: true,
headerTemplate: chrome(`<span>${esc(brand.name || "")}</span><span>${esc(docTitle)}</span>`),
footerTemplate: chrome(
`<span>${esc(brand.footerNote || "")}</span><span class="pageNumber"></span>`),
}, sessionId);
await writeFile(outPath, Buffer.from(data, "base64"));
console.log(`Wrote ${outPath}`);
if (parsed.headings.length) console.log(`${parsed.headings.length} headings, cover ${meta.title ? "on" : "off"}`);
} catch (e) {
console.error(`Render failed: ${e.message}`);
console.error(`The intermediate HTML is at ${htmlPath}. Open it in a browser to see what the page actually looks like.`);
process.exitCode = 2;
} finally {
await browser?.dispose();
if (!process.exitCode) await rm(htmlPath, { force: true }).catch(() => {});
}
assets/brand.example.json 621 B
{
"name": "Northside Electrical",
"accent": "#0B6EE8",
"ink": "#12161C",
"muted": "#5A6472",
"rule": "#DCE1E8",
"headingFont": "Georgia, 'Times New Roman', serif",
"bodyFont": "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
"monoFont": "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
"_logo": "a path relative to THIS file, e.g. logo.png. Rename to \"logo\" to use it",
"logo": null,
"footerNote": "Northside Electrical Pty Ltd | Commercial in confidence",
"pageSize": "A4",
"margin": { "top": "22mm", "bottom": "20mm", "left": "18mm", "right": "18mm" }
}
assets/business.example.json 793 B
{
"name": "Northside Electrical",
"period": "FY26",
"team": [
{
"role": "Senior technician",
"count": 2,
"wage": 82000,
"on_cost_pct": 26,
"hours_paid": 1976,
"billable_pct": 70
},
{
"role": "Technician",
"count": 2,
"wage": 68000,
"on_cost_pct": 26,
"hours_paid": 1976,
"billable_pct": 72
},
{
"role": "Apprentice",
"count": 1,
"wage": 41000,
"on_cost_pct": 24,
"hours_paid": 1976,
"billable_pct": 55
}
],
"overheads": {
"rent": 42000,
"vehicles": 58000,
"insurance": 18000,
"software": 9000,
"admin wages": 95000,
"other": 24000
},
"owner_salary": 120000,
"target_net_margin_pct": 12,
"materials_markup_pct": 20
}
assets/document.css 5.7 KB
/*
* Print stylesheet for render_pdf.mjs.
*
* The script injects a :root block above this file with --accent, --ink, --muted,
* --rule and the three font stacks, taken from the brand file. Everything here is
* expressed in those variables, so rebranding a document means editing brand.json
* and not this file.
*
* Page size and margins are set by the script through printToPDF, not by @page.
* Chrome applies printToPDF margins and @page margins to the same box, and having
* both set is the usual cause of a document whose margins mysteriously double.
*/
* { box-sizing: border-box; }
body.doc {
margin: 0;
font-family: var(--font-body);
font-size: 10.5pt;
line-height: 1.55;
color: var(--ink);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ------------------------------------------------------------------- cover */
.cover {
display: flex;
flex-direction: column;
justify-content: center;
/* Fills the printable box. 297mm less the 22mm and 20mm the script sets. */
min-height: 250mm;
}
.cover-logo { max-height: 18mm; max-width: 70mm; margin-bottom: 16mm; }
.cover-title {
font-family: var(--font-heading);
font-size: 34pt;
line-height: 1.12;
font-weight: 700;
margin: 0 0 4mm;
color: var(--ink);
}
.cover-subtitle {
font-size: 13pt;
color: var(--muted);
margin: 0 0 14mm;
font-weight: 400;
}
.cover-body::before {
content: "";
display: block;
width: 28mm;
height: 3pt;
background: var(--accent);
margin-bottom: 10mm;
}
.cover-meta {
display: grid;
grid-template-columns: 34mm 1fr;
gap: 2mm 6mm;
margin: 0;
font-size: 10pt;
}
.cover-meta dt { color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-size: 8pt; padding-top: 1pt; }
.cover-meta dd { margin: 0; font-weight: 600; }
.cover-confidential {
margin-top: 16mm;
font-size: 8pt;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
/* --------------------------------------------------------------- headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
color: var(--ink);
line-height: 1.25;
/* Never leave a heading alone at the foot of a page. */
break-after: avoid-page;
page-break-after: avoid;
break-inside: avoid-page;
}
main h1 { font-size: 22pt; margin: 0 0 6mm; }
h2 {
font-size: 15pt;
margin: 10mm 0 3mm;
padding-bottom: 2mm;
border-bottom: 0.7pt solid var(--rule);
}
h3 { font-size: 12pt; margin: 7mm 0 2mm; }
h4 { font-size: 10.5pt; margin: 5mm 0 1.5mm; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
main > h2:first-child, main > h1:first-child { margin-top: 0; }
/* ------------------------------------------------------------------- text */
p { margin: 0 0 3.2mm; orphans: 3; widows: 3; }
a { color: var(--accent); text-decoration: none; }
strong { font-weight: 650; }
ul, ol { margin: 0 0 3.2mm; padding-left: 6mm; }
li { margin-bottom: 1.4mm; break-inside: avoid; }
li > ul, li > ol { margin-top: 1.4mm; }
li.task { list-style: none; margin-left: -5mm; display: flex; gap: 2.5mm; align-items: baseline; }
li.task .box {
flex: 0 0 auto;
width: 3mm; height: 3mm;
border: 0.7pt solid var(--muted);
border-radius: 0.6mm;
}
li.task .box.on { background: var(--accent); border-color: var(--accent); }
blockquote {
margin: 0 0 3.2mm;
padding: 1mm 0 1mm 5mm;
border-left: 2pt solid var(--accent);
color: var(--muted);
break-inside: avoid;
}
blockquote p:last-child { margin-bottom: 0; }
hr { border: 0; border-top: 0.7pt solid var(--rule); margin: 7mm 0; }
code {
font-family: var(--font-mono);
font-size: 0.88em;
background: #F3F5F8;
padding: 0.3mm 1mm;
border-radius: 0.8mm;
}
pre.code {
font-family: var(--font-mono);
font-size: 8.5pt;
line-height: 1.45;
background: #F3F5F8;
border: 0.7pt solid var(--rule);
border-radius: 1.5mm;
padding: 3mm 4mm;
margin: 0 0 3.2mm;
white-space: pre-wrap; /* a long line wraps rather than being cut off the page */
word-break: break-word;
break-inside: avoid;
}
pre.code code { background: none; padding: 0; font-size: inherit; }
/* ----------------------------------------------------------------- tables */
table {
width: 100%;
border-collapse: collapse;
margin: 0 0 4mm;
font-size: 9.5pt;
break-inside: auto;
}
thead { display: table-header-group; } /* repeat the header on every page */
tr { break-inside: avoid; page-break-inside: avoid; }
th {
text-align: left;
font-weight: 650;
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
border-bottom: 1pt solid var(--ink);
padding: 2mm 2.5mm;
}
td { padding: 2mm 2.5mm; border-bottom: 0.5pt solid var(--rule); vertical-align: top; }
tbody tr:nth-child(even) { background: #FAFBFC; }
/* Right align a column of numbers by putting :--- in the markdown delimiter row. */
td[style*="right"], th[style*="right"] { font-variant-numeric: tabular-nums; }
/* ---------------------------------------------------------------- figures */
figure { margin: 0 0 4mm; break-inside: avoid; text-align: center; }
img { max-width: 100%; }
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }
/* -------------------------------------------------------------- contents */
.toc { break-after: page; page-break-after: always; }
.toc-title { border: 0; margin-top: 0; }
.toc ol { list-style: none; padding: 0; }
.toc li { margin-bottom: 2mm; }
.toc a { color: var(--ink); }
.toc-h3 { padding-left: 6mm; font-size: 9.5pt; color: var(--muted); }
.toc-h3 a { color: var(--muted); }
/* ----------------------------------------------------------------- breaks */
.pagebreak { break-after: page; page-break-after: always; height: 0; }
/* Put class="keep" on a heading to start its section on a fresh page. */
.keep { break-before: page; page-break-before: always; }
assets/quote-template.md 2.8 KB
---
title: Quote
subtitle: <What the job is, in their words>
client: <Customer name>
company: <Your business>
author: <Your name>
date: <Date>
version: <Quote number>
---
<!-- Render to PDF with the script that ships alongside this template:
node scripts/render_pdf.mjs quote.md --brand=brand.json
For a small job, delete the options table and state one price. For anything
over a few thousand, keep the options. -->
## The job
<One or two lines describing what they asked for, in their words. This is how they
check you understood, and it is the only part some customers read closely.>
| | |
|---|---|
| Site | <address> |
| Contact | <name, number> |
| Quote number | <> |
| Date | <> |
| Valid until | <30 days> |
## What we will do
| | |
|---:|---|
| <$8,400> | **<Supply and install new 24 pole distribution board>**<br><Includes all breakers, RCDs, labelling and testing> |
| <$1,200> | **<Decommission and remove existing board>** |
| <$900> | **<Certification and compliance documentation>** |
| <$500> | **<Making good to the immediate wall penetration>** |
<Group by outcome. Each line should be something the customer recognises as a thing
they want. Nothing individually shoppable.>
## Your options
| | What it covers | |
|---|---|---:|
| Essential | <The requirement, done properly. Never a bad job> | <$11,000> |
| **Recommended** | <The above, plus the thing most customers are glad they added> | **<$14,400>** |
| Complete | <The above, plus the long term arrangement> | <$19,800> |
<Delete this section for small jobs and state one price.>
## Not included
- <Three to six specific lines, from your own experience of what goes sideways>
- <>
- <>
## This price assumes
- <The numeric ones prevent the worst surprises>
- <>
## Timing
<From acceptance, not calendar dates, so the quote survives sitting on a desk.>
| | |
|---|---|
| Start | <Within X working days of acceptance> |
| On site | <X days> |
| Complete | <> |
<What you need from them, and by when.>
## Price and terms
**<$14,400>** <excluding / including> <sales tax / GST>.
| | |
|---|---|
| Deposit | <30% on acceptance> |
| Balance | <On completion> |
| Terms | <14 days from invoice> |
| Valid until | <date> |
**Changes to scope.** Any work outside this quote is priced in writing and approved by you before it starts. Nothing outside this quote is carried out without your written approval.
## What happens next
<Reply to this email with "approved" and we will book the work within five working
days. If you would rather talk it through first, call <name> on <number> any weekday.>
<I will follow up on <day> if I have not heard from you.>
| | |
|---|---|
| Accepted for <Customer> | |
| Name | |
| Signature | |
| Date | |
---
<Licence number. Insurance. Whatever a customer in your trade and jurisdiction
expects to see on a quote.>