summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-22 09:04:27 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-22 09:04:27 +0200
commit227d0422da0062410c85529bc999d7e509d9de78 (patch)
tree23102d1fb049b610ec154e7c136e3d92d941bd6d
parent8ceb1340a9030bb1030be61003dc65b444732b9e (diff)
downloadbokf-227d0422da0062410c85529bc999d7e509d9de78.tar.gz
bokf-227d0422da0062410c85529bc999d7e509d9de78.zip
documents: org name in the invoice and payslip headers, document_header_colorv0.1.63
-rw-r--r--clients/screens_settings.c1
-rw-r--r--docs/DECISIONS.md11
-rw-r--r--docs/INVOICING.md17
-rw-r--r--docs/PAYROLL.md8
-rw-r--r--docs/PROTOCOL.md14
-rw-r--r--docs/STATE.md9
-rw-r--r--src/cmd_invoices.c5
-rw-r--r--src/cmd_payroll.c4
-rw-r--r--src/cmd_settings.c17
-rw-r--r--src/invoice.c16
-rw-r--r--src/invoice.h1
-rw-r--r--src/payslip.c62
-rw-r--r--src/payslip.h1
-rw-r--r--src/pdf.c36
-rw-r--r--src/pdf.h6
-rw-r--r--src/util.c13
-rw-r--r--src/util.h1
-rw-r--r--tests/invoice_check.c54
-rw-r--r--tests/pdf_check.c9
-rw-r--r--tests/test_core.c108
20 files changed, 322 insertions, 71 deletions
diff --git a/clients/screens_settings.c b/clients/screens_settings.c
index 7730ff4..46ba201 100644
--- a/clients/screens_settings.c
+++ b/clients/screens_settings.c
@@ -342,6 +342,7 @@ static const struct setting_field INVOICE_SETTINGS[] = {
{ "invoice_revenue_account", "Intäktskonto", NULL, 0, 0, 0 },
{ "invoice_bankgiro", "Bankgiro", NULL, 0, 0, 0 },
{ "invoice_our_ref", "Vår referens (faktura)", NULL, 0, 0, 0 },
+ { "document_header_color", "Färg dokumenthuvud (#rrggbb)", NULL, 0, 0, 0 },
};
static const struct setting_field SERIES_SETTINGS[] = {
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 125d19b..6b3467b 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -227,6 +227,17 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21).
show `betald <datum>`. SMTP: `smtp_from`/`smtp_reply_to` are validated
as e-mail addresses (settings.set and mail config), with the sender's
display name taken from the org name.
+27. **Document header (2026-09-22)**: the invoice header bar no longer
+ draws the `MAKANDRA AB` Comfortaa outline; it prints the organization
+ name (`orgs.name`) in Helvetica-Bold, scaled down and truncated with
+ `...` if it would reach the `FAKTURA` wordmark, which stays. The shared
+ setting `document_header_color` (`#rrggbb`, default `#314c59`) colours
+ the header bar of **every** generated document — currently the invoice
+ and the lönebesked; `settings.set` refuses other values and the
+ renderers fall back to the default if a stored value is invalid. The
+ lönebesked prints the employer name in its header too, so the
+ `MAKANDRA AB` outlines are unused at runtime (kept in the generated
+ header). Images remain out of scope.
## Completed work formerly listed under "Pending decisions"
diff --git a/docs/INVOICING.md b/docs/INVOICING.md
index a04c869..de584cf 100644
--- a/docs/INVOICING.md
+++ b/docs/INVOICING.md
@@ -14,7 +14,8 @@ the generated PDF reproduces the existing document.
- Customer register (name, address, momsreg.nr, e-mail, er referens,
payment terms), owner-editable and audited.
- One-page invoice document generated by bokfd, visually matching the
- existing Google Sheets export (same grid, colours and wordmark).
+ existing Google Sheets export (same grid, colours and `FAKTURA` wordmark;
+ the header shows the organization name).
- A configurable, always-increasing invoice number series per org, plus an
OCR reference that Bankgiro accepts.
- Issue in one action: number + PDF (stored as an immutable attachment) +
@@ -49,13 +50,19 @@ Colours:
| Body text | `#314c59` |
| Header/table text on the bar | `#ffffff` |
+The header bar's colour is the shared document setting
+`document_header_color` (`#rrggbb`, default `#314c59`, also used by the
+lönebesked).
+
Fonts:
- Body: Helvetica (PDF base-14, metrically compatible with Arial); no
embedding.
-- Wordmark `MAKANDRA AB` and `FAKTURA`: Comfortaa Bold (SIL OFL) as
- pre-generated vector outlines, drawn as filled paths. No font file or
- TrueType machinery at runtime.
+- Wordmark `FAKTURA`: Comfortaa Bold (SIL OFL) as pre-generated vector
+ outlines, drawn as filled paths; no font file or TrueType machinery at
+ runtime. The header's left side prints the organization `name` in
+ Helvetica-Bold, scaled down and truncated with `...` if it would reach
+ `FAKTURA`.
Grid (points, origin top-left; refined against the originals in
`tests/` golden comparisons):
@@ -63,7 +70,7 @@ Grid (points, origin top-left; refined against the originals in
| Element | x | y |
|---|---|---|
| Header bar (x 17.3–577.7) | 17.3 | 53.3–75.7 |
-| Wordmark `MAKANDRA AB` (ink left/baseline) | 21.74 | 69.14 |
+| Header name (org `name`, ink left/baseline) | 21.74 | 69.14 |
| `FAKTURA` (ink right/baseline) | 576.87 | 69.14 |
| Info labels (bold 7.285 pt), right-aligned | 113.98 | 101.11 + 14.71/row |
| Info values (9.107 pt), left-aligned | 118.87 | same rows |
diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md
index b78aeba..ebf9272 100644
--- a/docs/PAYROLL.md
+++ b/docs/PAYROLL.md
@@ -216,9 +216,11 @@ the base is the gross.
## 6. Documents and TUI
- **Lönebesked** (wave 2, done): `payroll.payslip` renders one A4 page per
- employee and run with the invoice's visual language — dark `#314c59`
- header bar, employer and employee blocks, gross, preliminary tax (shown
- negative), net and the employer-contribution note; the personnummer is
+ employee and run with the invoice's visual language — the employer name in
+ a header bar in the shared document colour `document_header_color`
+ (default `#314c59`), employer and employee blocks, gross, preliminary tax
+ (shown negative), net and the employer-contribution note; the personnummer
+ is
masked except the last four. `payroll.payslip_mail` stores the PDF as an
`application/pdf` attachment on the run's voucher, links it with
`voucher_attachments` and e-mails it through the org's `smtp_*` settings
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 75f1990..081eb6a 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -374,7 +374,7 @@ resolved rows in a dry run.
| Command | Args | Notes |
|---|---|---|
| `settings.get` | — | effective org settings (defaults included); secret values replaced by `_set` flags |
-| `settings.set` | `key`, `value?` | known keys: `default_series`, `series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut`, `series_ib`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account`, `invoice_bankgiro`, `invoice_our_ref`, `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security`, `smtp_password` |
+| `settings.set` | `key`, `value?` | known keys: `default_series`, `series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut`, `series_ib`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account`, `invoice_bankgiro`, `invoice_our_ref`, `document_header_color`, `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security`, `smtp_password` |
`series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut` and
`series_ib` (1–8 characters, e.g. `A`, `V-`, `Å`) are the number series each
@@ -391,7 +391,10 @@ only, up to 10 characters, default `1930`) is the account `bank.import` uses
when the request carries no `account`. `invoice_receivable_account` (default
`1510`) and `invoice_revenue_account` (default `3001`) are the receivable and
default revenue account of invoice postings, digits only, up to 10
-characters. Verification ids are the concatenation of series and number
+characters. `document_header_color` (`#rrggbb`, default `#314c59`) is the
+background colour of the generated documents' header bars (invoice and
+lönebesked); the invoice prints the org name on it. Verification ids are the
+concatenation of series and number
(`V-8`), and series are free-form: only an unbroken numbering per series is
required.
@@ -644,7 +647,9 @@ is the idempotency key).
`invoice.sequence_set` and `invoice.issue` are audited; `invoice.issue`
supports `dry_run`, which validates and renders but takes no number and
writes nothing. `invoice.pdf` returns the stored document as base64
-(`JVBERi0` after decoding is the PDF magic). When the setting
+(`JVBERi0` after decoding is the PDF magic). The document's header bar
+prints the organization `name` and is coloured by `document_header_color`
+(default `#314c59`; invalid stored values fall back to it). When the setting
`invoice_bankgiro` is present it is printed in the document's Bankgiro field;
`invoice_our_ref` (up to 64 characters) prefills the invoice form's "Vår
referens".
@@ -756,7 +761,8 @@ given date (default: today) and sets the run's `status` to `paid` with
`payment_voucher_id`; paying twice is a `CONFLICT`.
`payroll.payslip` renders one A4 lönebesked for an employee line of a posted
-run: the employer header and footer, the employee name, the masked
+run: the employer name in the header bar (coloured by
+`document_header_color`), the employer footer, the employee name, the masked
personnummer (`********-1234`, or `********` when the key is unavailable),
period, pay date, tax table and column, then Bruttolön, the negative
Preliminärskatt, a rule and Nettolön, and the note `Arbetsgivaravgifter
diff --git a/docs/STATE.md b/docs/STATE.md
index b08471a..0102f54 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -42,6 +42,15 @@ unit tests and the docs consistency check.
validation. `test_core` has `invoice_extras`; the pty suite has
`invoice-duplicate` and `invoice-pay`. Remaining invoice work is in the
backlog (credit notes, per-row account, kundreskontra, partial payments).
+- **Document header (2026-09-22)**: the invoice header bar prints the
+ organization name instead of the hard-coded `MAKANDRA AB` outline (scaled
+ down and truncated with `...` if long). The shared document setting
+ `document_header_color` (`#rrggbb`, default `#314c59`, editable in
+ Bolaget → Fakturauppgifter) colours the header bar of both the invoice
+ and the lönebesked, and the lönebesked prints the employer name there too
+ (it previously used the Comfortaa wordmark). The invoice's `FAKTURA`
+ outline is unchanged; the `MAKANDRA AB` outlines in `src/wordmark.h` are
+ now unused at runtime.
- **Mail configuration**: Makandra AB (org 2) has **no** `smtp_*` settings
in bokf, so `invoice.send` there is `SMTP_NOT_CONFIGURED` (fine if
invoices are sent elsewhere — set them up when wanted). Mock AB (org 1)
diff --git a/src/cmd_invoices.c b/src/cmd_invoices.c
index 2a6a9c6..a9d3fb1 100644
--- a/src/cmd_invoices.c
+++ b/src/cmd_invoices.c
@@ -380,6 +380,7 @@ struct invoice_view {
char seller_org_nr[64];
char seller_vat_nr[64];
char bankgiro[64];
+ char header_color[8];
char customer_name[256];
char customer_address[1024];
char customer_postal[64];
@@ -467,6 +468,9 @@ static int invoice_view_fill(struct req *r, const struct invoice_draft *d,
bankgiro && *bankgiro ? bankgiro : "");
free(bankgiro);
+ db_setting_copy(r->db, r->org_id, "document_header_color", "",
+ v->header_color, sizeof v->header_color);
+
v->lines = xcalloc(d->nlines, sizeof *v->lines);
for (size_t i = 0; i < d->nlines; i++) {
v->lines[i].article_no = d->lines[i].article_no;
@@ -489,6 +493,7 @@ static int invoice_view_fill(struct req *r, const struct invoice_draft *d,
v->doc.seller.org_nr = v->seller_org_nr;
v->doc.seller.vat_nr = v->seller_vat_nr;
v->doc.seller.bankgiro = v->bankgiro;
+ v->doc.header_color = v->header_color;
v->doc.customer.name = v->customer_name;
v->doc.customer.address = v->customer_address;
v->doc.customer.postal_code = v->customer_postal;
diff --git a/src/cmd_payroll.c b/src/cmd_payroll.c
index 84ae323..6dea293 100644
--- a/src/cmd_payroll.c
+++ b/src/cmd_payroll.c
@@ -762,6 +762,7 @@ struct payslip_ctx {
char employer_org_nr[64];
char employer_phone[64];
char employer_email[256];
+ char header_color[8];
char filename[320];
int64_t voucher_id;
int64_t employee_id;
@@ -924,6 +925,8 @@ static int payslip_prepare(struct req *r, struct payslip_ctx *c)
struct payroll_cfg cfg;
payroll_cfg_load(r, &cfg);
snprintf(c->run_ref, sizeof c->run_ref, "Lönekörning %s", c->period);
+ db_setting_copy(r->db, r->org_id, "document_header_color", "",
+ c->header_color, sizeof c->header_color);
char safe_name[200];
snprintf(safe_name, sizeof safe_name, "%s", c->employee_name);
@@ -942,6 +945,7 @@ static int payslip_prepare(struct req *r, struct payslip_ctx *c)
c->d.employer.email = c->employer_email;
c->d.employee_name = c->employee_name;
c->d.personal_no_masked = c->personal_no;
+ c->d.header_color = c->header_color;
c->d.period = c->period;
c->d.pay_date = c->pay_date;
c->d.run_ref = c->run_ref;
diff --git a/src/cmd_settings.c b/src/cmd_settings.c
index f41f904..5bac3d9 100644
--- a/src/cmd_settings.c
+++ b/src/cmd_settings.c
@@ -22,7 +22,7 @@ static yyjson_mut_val *h_settings_get(struct req *r)
int have_default = 0, have_bank = 0, have_receivable = 0, have_revenue = 0;
int have_password = 0, have_security = 0;
int have_voucher = 0, have_invoice = 0, have_payroll = 0;
- int have_bokslut = 0, have_ib = 0;
+ int have_bokslut = 0, have_ib = 0, have_header_color = 0;
char legacy_series[16] = "A";
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(
@@ -59,6 +59,8 @@ static yyjson_mut_val *h_settings_get(struct req *r)
have_receivable = 1;
} else if (strcmp(k, "invoice_revenue_account") == 0) {
have_revenue = 1;
+ } else if (strcmp(k, "document_header_color") == 0) {
+ have_header_color = 1;
} else if (strcmp(k, "smtp_security") == 0) {
have_security = 1;
}
@@ -87,6 +89,9 @@ static yyjson_mut_val *h_settings_get(struct req *r)
if (!have_revenue)
yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_revenue_account",
"3001");
+ if (!have_header_color)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "document_header_color",
+ "#314c59");
if (!have_security)
yyjson_mut_obj_add_strcpy(r->rdoc, o, "smtp_security", "starttls");
yyjson_mut_obj_add_bool(r->rdoc, o, "smtp_password_set", have_password);
@@ -162,7 +167,7 @@ static yyjson_mut_val *h_settings_set(struct req *r)
return settings_result(r, key, "[redacted]", 0);
}
size_t maxlen;
- int digits_only = 0, bankgiro = 0, port = 0, security = 0;
+ int digits_only = 0, bankgiro = 0, port = 0, security = 0, color = 0;
if (strcmp(key, "default_series") == 0 ||
strcmp(key, "series_voucher") == 0 ||
strcmp(key, "series_invoice") == 0 ||
@@ -193,6 +198,9 @@ static yyjson_mut_val *h_settings_set(struct req *r)
bankgiro = 1;
} else if (strcmp(key, "invoice_our_ref") == 0) {
maxlen = 64;
+ } else if (strcmp(key, "document_header_color") == 0) {
+ maxlen = 7;
+ color = 1;
} else
return fail(r, "UNSUPPORTED", "unknown setting");
size_t len = strlen(value);
@@ -209,6 +217,8 @@ static yyjson_mut_val *h_settings_set(struct req *r)
return failf(r, "INVALID_ARGS",
"%s must not contain control characters", key);
}
+ if (color && !util_hex_color_valid(value))
+ return failf(r, "INVALID_ARGS", "%s must be #rrggbb", key);
if (port) {
long v = strtol(value, NULL, 10);
if (v < 1 || v > 65535)
@@ -252,7 +262,8 @@ static yyjson_mut_val *h_settings_set(struct req *r)
static const struct cmd_arg args_settings_set[] = {
{ "key", ARG_STR, 1, NULL, NULL,
"default_series, attachment_dir, bank_account,"
- " invoice_receivable_account, invoice_revenue_account, smtp_host,"
+ " invoice_receivable_account, invoice_revenue_account,"
+ " invoice_bankgiro, invoice_our_ref, document_header_color, smtp_host,"
" smtp_port, smtp_user, smtp_from, smtp_reply_to, smtp_security or"
" smtp_password" },
{ "value", ARG_STR, 0, NULL, NULL,
diff --git a/src/invoice.c b/src/invoice.c
index 34b2a85..1bdc0da 100644
--- a/src/invoice.c
+++ b/src/invoice.c
@@ -77,8 +77,12 @@
#define FOOT_CITY_Y 686.0238
#define FOOT_PAGE_Y 819.7954
-#define WORDMARK_X 21.742
+#define HEADER_NAME_X 21.742
#define FAKTURA_X 498.400
+#define HEADER_BASE_Y 69.1358
+#define HEADER_NAME_SIZE 16.0
+#define HEADER_NAME_MIN_SIZE 8.0
+#define HEADER_NAME_MAX_W 460.0
static void text_at(struct pdf *p, double x, double base, const char *font,
double size, const char *rgb, const char *s)
@@ -581,9 +585,13 @@ int invoice_render_pdf(const struct invoice_doc *d, unsigned char **out,
p = pdf_new();
pdf_page(p);
pdf_fill_rect(p, PAGE_LEFT, BAR_HEADER_Y, PAGE_RIGHT_X - PAGE_LEFT,
- BAR_HEADER_H, INK);
- draw_wordmark(p, WORDMARK_MAKANDRA, WORDMARK_X, 69.1358);
- draw_wordmark(p, WORDMARK_FAKTURA, FAKTURA_X, 69.1358);
+ BAR_HEADER_H,
+ util_hex_color_valid(d->header_color) ? d->header_color
+ : INK);
+ pdf_text_fit(p, HEADER_NAME_X, HEADER_BASE_Y, "HB", HEADER_NAME_SIZE,
+ HEADER_NAME_MIN_SIZE, HEADER_NAME_MAX_W, WHITE,
+ d->seller.name);
+ draw_wordmark(p, WORDMARK_FAKTURA, FAKTURA_X, HEADER_BASE_Y);
draw_info(p, d);
draw_customer(p, &d->customer);
draw_table(p, d);
diff --git a/src/invoice.h b/src/invoice.h
index 7d64f21..55d1e3b 100644
--- a/src/invoice.h
+++ b/src/invoice.h
@@ -41,6 +41,7 @@ struct invoice_doc {
struct invoice_customer customer;
int64_t number;
const char *ocr;
+ const char *header_color; /* "#rrggbb"; NULL or invalid = default */
const char *invoice_date;
const char *due_date;
const char *delivery_date;
diff --git a/src/payslip.c b/src/payslip.c
index 81ebded..ee8e008 100644
--- a/src/payslip.c
+++ b/src/payslip.c
@@ -6,7 +6,6 @@
#include "pdf.h"
#include "util.h"
-#include "wordmark.h"
#define INK "#314c59"
#define WHITE "#ffffff"
@@ -52,9 +51,12 @@
#define FOOT_PAGE_Y 819.7954
#define FOOT_PAGE_RIGHT 567.4275
-#define WORDMARK_X 21.742
+#define HEADER_NAME_X 21.742
#define TITLE_RIGHT 574.892
#define TITLE_BASE_Y 69.1358
+#define HEADER_NAME_SIZE 16.0
+#define HEADER_NAME_MIN_SIZE 8.0
+#define HEADER_NAME_MAX_W 460.0
static void text_at(struct pdf *p, double x, double base, const char *font,
double size, const char *rgb, const char *s)
@@ -72,54 +74,6 @@ static void text_right(struct pdf *p, double right, double base,
rgb, s);
}
-/* pdf_path consumes SVG-style y-down paths; wordmark.h stores y-up outline
- coordinates, so negate the y of every point before drawing. */
-static void draw_wordmark(struct pdf *p, const char *path, double x, double y)
-{
- size_t n = strlen(path);
- char *flipped = xmalloc(2 * n + 2);
- char op = 0;
- int num = 0, in_num = 0, y_coord = 0;
- size_t o = 0;
-
- for (size_t i = 0; i < n; i++) {
- char c = path[i];
-
- if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
- if (c == 'M' || c == 'L' || c == 'C') {
- op = c;
- num = 0;
- } else if (c == 'Z') {
- op = 0;
- }
- flipped[o++] = c;
- in_num = 0;
- continue;
- }
- if (!in_num && (c == '-' || c == '+' || c == '.' ||
- (c >= '0' && c <= '9'))) {
- in_num = 1;
- y_coord = op && (num % 2 == 1);
- num++;
- if (y_coord) {
- if (c == '-') {
- i++;
- c = path[i];
- } else {
- flipped[o++] = '-';
- }
- }
- } else if (!(c == '-' || c == '+' || c == '.' ||
- (c >= '0' && c <= '9'))) {
- in_num = 0;
- }
- flipped[o++] = c;
- }
- flipped[o] = '\0';
- pdf_path(p, flipped, x, y, WHITE);
- free(flipped);
-}
-
static void group_digits(char *out, size_t n, uint64_t v)
{
char digits[24];
@@ -284,8 +238,12 @@ int payslip_render(const struct payslip_data *d, unsigned char **out,
p = pdf_new();
pdf_page(p);
pdf_fill_rect(p, PAGE_LEFT, BAR_HEADER_Y, PAGE_RIGHT_X - PAGE_LEFT,
- BAR_HEADER_H, INK);
- draw_wordmark(p, WORDMARK_MAKANDRA, WORDMARK_X, TITLE_BASE_Y);
+ BAR_HEADER_H,
+ util_hex_color_valid(d->header_color) ? d->header_color
+ : INK);
+ pdf_text_fit(p, HEADER_NAME_X, TITLE_BASE_Y, "HB", HEADER_NAME_SIZE,
+ HEADER_NAME_MIN_SIZE, HEADER_NAME_MAX_W, WHITE,
+ d->employer.name);
text_right(p, TITLE_RIGHT, TITLE_BASE_Y, "HB", SIZE_TITLE, WHITE,
"LÖNEBESKED");
pdf_line(p, PAGE_LEFT, RULE_HEADER_Y, PAGE_RIGHT_X, RULE_HEADER_Y, RULE_W,
diff --git a/src/payslip.h b/src/payslip.h
index 291b35d..581cd81 100644
--- a/src/payslip.h
+++ b/src/payslip.h
@@ -16,6 +16,7 @@ struct payslip_data {
} employer;
const char *employee_name;
const char *personal_no_masked;
+ const char *header_color; /* "#rrggbb"; NULL or invalid = default */
const char *period;
const char *pay_date;
int tax_table;
diff --git a/src/pdf.c b/src/pdf.c
index c6ae876..5718ed7 100644
--- a/src/pdf.c
+++ b/src/pdf.c
@@ -353,6 +353,42 @@ double pdf_font_ascent(const char *font, double size)
return size > 0 ? size * PDF_ASCENT / 1000.0 : 0.0;
}
+void pdf_text_fit(struct pdf *p, double x, double baseline, const char *font,
+ double size, double min_size, double max_w, const char *rgb,
+ const char *utf8)
+{
+ char *truncated = NULL;
+ const char *text = utf8;
+ double w;
+
+ if (!utf8 || !*utf8 || max_w <= 0)
+ return;
+ w = pdf_text_width(font, size, utf8);
+ if (w > max_w) {
+ size = size * max_w / w;
+ if (size < min_size)
+ size = min_size;
+ w = pdf_text_width(font, size, utf8);
+ }
+ if (w > max_w) {
+ size_t n = strlen(utf8);
+
+ truncated = xmalloc(n + 4);
+ memcpy(truncated, utf8, n);
+ while (n > 0) {
+ memcpy(truncated + n, "...", 4);
+ if (pdf_text_width(font, size, truncated) <= max_w)
+ break;
+ n--;
+ while (n > 0 && ((unsigned char)truncated[n] & 0xC0) == 0x80)
+ n--;
+ }
+ text = truncated;
+ }
+ pdf_text(p, x, baseline, font, size, rgb, text);
+ free(truncated);
+}
+
static int parse_num(const char **sp, double *out)
{
const char *s = *sp;
diff --git a/src/pdf.h b/src/pdf.h
index c6f95ac..9ed4595 100644
--- a/src/pdf.h
+++ b/src/pdf.h
@@ -20,6 +20,12 @@ void pdf_text(struct pdf *p, double x, double baseline, const char *font,
double size, const char *rgb, const char *utf8);
double pdf_text_width(const char *font, double size, const char *utf8);
double pdf_font_ascent(const char *font, double size);
+/* Draws utf8 at x/baseline, shrinking the size (down to min_size) to fit
+ max_w; if it still does not fit, truncates at a whole UTF-8 character so
+ the text ends with "...". */
+void pdf_text_fit(struct pdf *p, double x, double baseline, const char *font,
+ double size, double min_size, double max_w, const char *rgb,
+ const char *utf8);
void pdf_path(struct pdf *p, const char *path, double x, double y,
const char *rgb);
unsigned char *pdf_finish(struct pdf *p, size_t *len);
diff --git a/src/util.c b/src/util.c
index 9543875..7882642 100644
--- a/src/util.c
+++ b/src/util.c
@@ -276,6 +276,19 @@ char *util_str_trim(char *s)
return s;
}
+int util_hex_color_valid(const char *s)
+{
+ if (!s || s[0] != '#' || strlen(s) != 7)
+ return 0;
+ for (int i = 1; i < 7; i++) {
+ char c = s[i];
+ if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
+ (c >= 'A' && c <= 'F')))
+ return 0;
+ }
+ return 1;
+}
+
static int is_leap(int y)
{
return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
diff --git a/src/util.h b/src/util.h
index d854f76..a94cf16 100644
--- a/src/util.h
+++ b/src/util.h
@@ -32,6 +32,7 @@ int util_const_eq(const void *a, const void *b, size_t n);
int64_t util_now(void);
void util_iso8601(int64_t t, char *buf, size_t n);
char *util_str_trim(char *s);
+int util_hex_color_valid(const char *s);
int util_parse_iso_date(const char *s);
/* base64 (standard alphabet, padding required) */
diff --git a/tests/invoice_check.c b/tests/invoice_check.c
index 01b5ca3..e61a41d 100644
--- a/tests/invoice_check.c
+++ b/tests/invoice_check.c
@@ -129,6 +129,19 @@ static int contains(const unsigned char *data, size_t len, const char *needle)
return 0;
}
+static int count_of(const unsigned char *data, size_t len, const char *needle)
+{
+ size_t n = strlen(needle);
+ int count = 0;
+
+ if (!n)
+ return 0;
+ for (size_t i = 0; i + n <= len; i++)
+ if (memcmp(data + i, needle, n) == 0)
+ count++;
+ return count;
+}
+
static void test_render(const char *path)
{
struct invoice_doc d;
@@ -147,6 +160,8 @@ static void test_render(const char *path)
if (data) {
check(memcmp(data, "%PDF-1.4", 8) == 0, "render header");
check(contains(data, len, "Testleverant"), "render seller text");
+ check(count_of(data, len, "Testleverant") == 2,
+ "render seller name in header and footer");
check(contains(data, len, "Testkund AB"), "render customer text");
check(contains(data, len, "Summa att betala SEK"), "render summary");
check(contains(data, len, "Test Person"), "render our ref");
@@ -166,6 +181,45 @@ static void test_render(const char *path)
free(data);
{
+ unsigned char *colored = NULL;
+ size_t colored_len = 0;
+
+ d.header_color = "#ff0000";
+ check(invoice_render_pdf(&d, &colored, &colored_len) == 0,
+ "render custom colour returns 0");
+ if (colored)
+ check(contains(colored, colored_len, "1 0 0 rg"),
+ "render custom header colour");
+ free(colored);
+
+ colored = NULL;
+ d.header_color = "not-a-colour";
+ check(invoice_render_pdf(&d, &colored, &colored_len) == 0,
+ "render invalid colour returns 0");
+ if (colored)
+ check(!contains(colored, colored_len, "1 0 0 rg"),
+ "render invalid colour falls back");
+ free(colored);
+ d.header_color = NULL;
+ }
+ {
+ static char long_name[240];
+ unsigned char *long_pdf = NULL;
+ size_t long_len = 0;
+
+ memset(long_name, 'A', sizeof long_name - 1);
+ long_name[sizeof long_name - 1] = '\0';
+ d.seller.name = long_name;
+ check(invoice_render_pdf(&d, &long_pdf, &long_len) == 0,
+ "render long name returns 0");
+ if (long_pdf)
+ check(contains(long_pdf, long_len, "..."),
+ "render long name is truncated");
+ free(long_pdf);
+ d.seller.name = "Testleverantör AB";
+ }
+
+ {
unsigned char *bad = NULL;
size_t n = 0;
diff --git a/tests/pdf_check.c b/tests/pdf_check.c
index 4d56cab..4eaf24d 100644
--- a/tests/pdf_check.c
+++ b/tests/pdf_check.c
@@ -82,6 +82,12 @@ int main(int argc, char **argv)
pdf_line(p, 0, 80, 595, 80, 0.5, "#314c59");
pdf_line(p, 20, 100, 575, 100, 0.25, "#314c59");
+ pdf_text_fit(p, 20, 220, "HB", 16, 8, 200, "#314c59", "Fitted");
+ pdf_text_fit(p, 20, 240, "H", 10, 8, 40, "#314c59",
+ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
+ pdf_text_fit(p, 20, 260, "H", 10, 8, 40, "#314c59", NULL);
+ pdf_text_fit(p, 20, 260, "H", 10, 8, 40, "#314c59", "");
+
put_text(p, "MAKANDRA", 20, 70, "HB", 16, "#ffffff");
put_text(p, "FAKTURA", 575 - pdf_text_width("H", 16, "FAKTURA"), 70,
"H", 16, "#ffffff");
@@ -123,6 +129,9 @@ int main(int argc, char **argv)
check(contains(data, len, "1 0 obj"), "object 1");
check(contains(data, len, "6 0 obj"), "object 6");
check(!contains(data, len, "\r"), "no CR");
+ check(contains(data, len, "Fitted"), "text_fit keeps short text");
+ check(contains(data, len, "(AAAAAA...)"),
+ "text_fit truncates long text");
if (argc > 1) {
FILE *f = fopen(argv[1], "wb");
diff --git a/tests/test_core.c b/tests/test_core.c
index e56c5d3..a000ffe 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -40,6 +40,18 @@ static void mark_failed(void)
g_failed[g_failed_n++] = g_test;
}
+static int count_of(const char *text, const char *needle)
+{
+ size_t n = strlen(needle);
+ int count = 0;
+
+ if (!n)
+ return 0;
+ for (const char *p = text; (p = strstr(p, needle)) != NULL; p++)
+ count++;
+ return count;
+}
+
struct tctx {
char tmpdir[512];
char backupdir[512];
@@ -2336,6 +2348,50 @@ static void test_settings(struct tctx *t)
CHECK_OK(d);
CHECK_STR(d, "result.value", "anders@example.com");
yyjson_doc_free(d);
+
+ /* the invoice header colour must be #rrggbb */
+ d = call(reqf("{\"v\":1,\"id\":\"hdr0\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.document_header_color", "#314c59");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"hdr1\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"#1a2b3c\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "#1a2b3c");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"hdr2\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"1a2b3c\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"hdr3\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"#12345\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"hdr4\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"#1234567\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"hdr5\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"#GGGGGG\"}}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
}
static void test_series(struct tctx *t)
@@ -3316,6 +3372,41 @@ static void test_invoices(struct tctx *t)
CHECK(stored && preview_b64 && strcmp(stored, preview_b64) == 0);
yyjson_doc_free(d);
+ /* the invoice header colour setting reaches the rendered document */
+ d = call(reqf("{\"v\":1,\"id\":\"354a\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"#ff0000\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"354b\",\"cmd\":\"invoice.preview\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":%s}",
+ g_session, (int)t->org_id, draft_args));
+ CHECK_OK(d);
+ const char *red_b64 = jstr(d, "result.content_base64");
+ unsigned char *red_pdf = NULL;
+ size_t red_len = 0;
+ CHECK(red_b64 &&
+ util_b64_decode(red_b64, strlen(red_b64), &red_pdf, &red_len) == 0);
+ if (red_pdf) {
+ char *red_text = xmalloc(red_len + 1);
+
+ memcpy(red_text, red_pdf, red_len);
+ red_text[red_len] = '\0';
+ CHECK(strstr(red_text, "1 0 0 rg") != NULL);
+ free(red_text);
+ }
+ free(red_pdf);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"354c\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"#314c59\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
d = call(reqf("{\"v\":1,\"id\":\"355\",\"cmd\":\"invoice.issue\","
"\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
"%lld,\"invoice_date\":\"2026-09-21\",\"due_date\":"
@@ -5085,6 +5176,14 @@ static void test_payslip(struct tctx *t)
CHECK_STR(d, "error.code", "NOT_FOUND");
yyjson_doc_free(d);
+ /* the shared document header colour reaches the payslip too */
+ d = call(reqf("{\"v\":1,\"id\":\"ps5b\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"#ff0000\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
char req[512];
snprintf(req, sizeof req,
"{\"v\":1,\"id\":\"ps6\",\"cmd\":\"payroll.payslip\","
@@ -5116,12 +5215,21 @@ static void test_payslip(struct tctx *t)
CHECK(strstr(text, "-4 208,00") != NULL);
CHECK(strstr(text, "Arbetsgivaravgifter 31,42 % betalas av"
" arbetsgivaren.") != NULL);
+ CHECK(strstr(text, "1 0 0 rg") != NULL);
+ CHECK(count_of(text, "AB Ett") == 2);
free(text);
}
free(pdf);
}
yyjson_doc_free(rd);
free(raw);
+
+ d = call(reqf("{\"v\":1,\"id\":\"ps7\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"key\":"
+ "\"document_header_color\",\"value\":\"#314c59\"}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
}
static void test_payslip_mail(struct tctx *t)