diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-22 09:04:27 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-22 09:04:27 +0200 |
| commit | 227d0422da0062410c85529bc999d7e509d9de78 (patch) | |
| tree | 23102d1fb049b610ec154e7c136e3d92d941bd6d | |
| parent | 8ceb1340a9030bb1030be61003dc65b444732b9e (diff) | |
| download | bokf-0.1.63.tar.gz bokf-0.1.63.zip | |
documents: org name in the invoice and payslip headers, document_header_colorv0.1.63
| -rw-r--r-- | clients/screens_settings.c | 1 | ||||
| -rw-r--r-- | docs/DECISIONS.md | 11 | ||||
| -rw-r--r-- | docs/INVOICING.md | 17 | ||||
| -rw-r--r-- | docs/PAYROLL.md | 8 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 14 | ||||
| -rw-r--r-- | docs/STATE.md | 9 | ||||
| -rw-r--r-- | src/cmd_invoices.c | 5 | ||||
| -rw-r--r-- | src/cmd_payroll.c | 4 | ||||
| -rw-r--r-- | src/cmd_settings.c | 17 | ||||
| -rw-r--r-- | src/invoice.c | 16 | ||||
| -rw-r--r-- | src/invoice.h | 1 | ||||
| -rw-r--r-- | src/payslip.c | 62 | ||||
| -rw-r--r-- | src/payslip.h | 1 | ||||
| -rw-r--r-- | src/pdf.c | 36 | ||||
| -rw-r--r-- | src/pdf.h | 6 | ||||
| -rw-r--r-- | src/util.c | 13 | ||||
| -rw-r--r-- | src/util.h | 1 | ||||
| -rw-r--r-- | tests/invoice_check.c | 54 | ||||
| -rw-r--r-- | tests/pdf_check.c | 9 | ||||
| -rw-r--r-- | tests/test_core.c | 108 |
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; @@ -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; @@ -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); @@ -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; @@ -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) |
