diff --git a/CLAUDE.md b/CLAUDE.md index 23fc8c0..9412f77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ _Common/ — shared headers (extract_text.hpp) Annotations/ — 5 samples ContentCreation/ — 16 samples ContentModification/ — 14 samples -DocumentConversion/ — 7 samples +DocumentConversion/ — 10 samples DocumentOptimization/— 1 sample Forms/ — 4 samples Images/ — 14 samples diff --git a/DocumentConversion/CreateInvoiceFromStructuredData/CreateInvoiceFromStructuredData.vcxproj b/DocumentConversion/CreateInvoiceFromStructuredData/CreateInvoiceFromStructuredData.vcxproj new file mode 100644 index 0000000..deb4113 --- /dev/null +++ b/DocumentConversion/CreateInvoiceFromStructuredData/CreateInvoiceFromStructuredData.vcxproj @@ -0,0 +1,114 @@ + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + 17.0 + CreateInvoiceFromStructuredData + CreateInvoiceFromStructuredData + + + + Application + true + v143 + + + Application + false + v143 + true + + + Application + true + v143 + + + Application + false + v143 + true + + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + stdcpp17 + /utf-8 %(AdditionalOptions) + + + Console + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + stdcpp17 + /utf-8 %(AdditionalOptions) + + + Console + true + true + true + + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + stdcpp17 + /utf-8 %(AdditionalOptions) + + + Console + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + stdcpp17 + /utf-8 %(AdditionalOptions) + + + Console + true + true + true + + + + + + + diff --git a/DocumentConversion/CreateInvoiceFromStructuredData/Makefile b/DocumentConversion/CreateInvoiceFromStructuredData/Makefile new file mode 100644 index 0000000..2f03dc2 --- /dev/null +++ b/DocumentConversion/CreateInvoiceFromStructuredData/Makefile @@ -0,0 +1,32 @@ +UNAME := $(shell uname) + +CXX ?= $(if $(filter Darwin,$(UNAME)),clang++,g++) + +TARGET = create_invoice_from_structured_data +SRC = create_invoice_from_structured_data.cpp + +SDK_ROOT ?= ../../.. + +CXXFLAGS = -std=c++17 -I$(SDK_ROOT)/include +LDFLAGS = -L$(SDK_ROOT)/lib -ldatalogics_interface_api + +SDK_LIB := $(abspath $(SDK_ROOT)/lib) + +ifeq ($(UNAME),Darwin) + LDFLAGS += -Wl,-rpath,$(SDK_LIB) +else + LDFLAGS += -Wl,-rpath,$(SDK_LIB) +endif + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CXX) $(CXXFLAGS) -o $@ $< $(LDFLAGS) + +clean: + rm -f $(TARGET) + +run: $(TARGET) + ./$(TARGET) + +.PHONY: all clean run diff --git a/DocumentConversion/CreateInvoiceFromStructuredData/README.md b/DocumentConversion/CreateInvoiceFromStructuredData/README.md new file mode 100644 index 0000000..a5e2c18 --- /dev/null +++ b/DocumentConversion/CreateInvoiceFromStructuredData/README.md @@ -0,0 +1,63 @@ +# CreateInvoiceFromStructuredData + +Builds a tagged PDF invoice from structured input files with the Datalogics +C++ APDFL API: JSON metadata (seller, customer, dates, tax rate), a CSV of +line items, a JSON style sheet (fonts, colors, page dimensions), and a local +PNG logo. Everything is drawn directly with APDFL — text positioned with real +font metrics, boxes and rules as vector paths, and the logo placed with +`Image` — while the logical structure tree is built alongside the content so +the invoice is accessible. + +The generated invoice includes: + +- A header with the scaled logo (tagged `Figure` with alt text), the invoice + number, and issue/due dates +- "From" and "Bill To" cards for the seller and customer +- A line-item table (`Table` → `TR` → `TH`/`TD`) whose header row repeats on + every continuation page, with right-aligned quantities and currency values +- A totals box with subtotal, tax, and grand total +- Payment terms and notes sections, and a decorative footer emitted as + artifact content outside the structure tree + +When `applyRestrictionPassword` is enabled in the metadata (the default), the +document is secured with `Document::secure` using 256-bit AES: it opens, +prints, and remains accessible without a password, but editing requires the +owner password from the metadata file. + +## Building and running + +Run from this directory so the default `data/` paths resolve: + +```sh +make # or open CreateInvoiceFromStructuredData.vcxproj in Visual Studio +./create_invoice_from_structured_data # uses the bundled data files +./create_invoice_from_structured_data --help # all options +./create_invoice_from_structured_data --self-test # validate inputs, no PDF written +``` + +Options: `--metadata `, `--line-items `, `--style `, +`--output `. The logo path in the metadata file is resolved relative to +the metadata file's directory. + +Set `APDFL_LICENSE_KEY` to provide a Datalogics APDFL activation key before +the library initializes. + +## Input files + +- `data/metadata.json` — invoice number, dates, currency, tax rate, seller + and customer contact blocks, logo path, and the optional restriction + password. All data is fictional. +- `data/line-items.csv` — `itemCode,description,quantity,unitPrice` rows; + quoted fields may contain commas. +- `data/style.json` — fonts, font sizes, page size, margin, logo bounds, and + `#RRGGBB` colors. +- `images/northstar-logo.png` — the sample logo. + +## Differences from the .NET sample + +This is a rewrite of the `apdfl-csharp-dotnet-samples` sample of the same +name using the Modern C++ interface. The Modern C++ API assigns marked-content +IDs and maintains the structure parent tree automatically +(`StructElement::add_marked_content_ref`), so the tagging code is +substantially simpler than the .NET version. JSON and CSV parsing is done +with a small parser inside the sample, so APDFL remains the only dependency. diff --git a/DocumentConversion/CreateInvoiceFromStructuredData/create_invoice_from_structured_data.cpp b/DocumentConversion/CreateInvoiceFromStructuredData/create_invoice_from_structured_data.cpp new file mode 100644 index 0000000..bbca505 --- /dev/null +++ b/DocumentConversion/CreateInvoiceFromStructuredData/create_invoice_from_structured_data.cpp @@ -0,0 +1,1323 @@ +/* + * This sample builds a tagged PDF invoice from structured input files — JSON + * metadata, a CSV of line items, a JSON style sheet, and a local PNG logo — + * using the Datalogics C++ APDFL API directly. It lays out a styled invoice + * (header with logo, seller/customer cards, a line-item table that repeats + * its header across pages, a totals box, and payment notes), emits logical + * structure (Document, Sect, H1/H2, P, Figure with alt text, Table/TR/TH/TD) + * so the output is accessible, and optionally applies an owner-password + * restriction so the invoice can be viewed and printed but not edited. + * + * Copyright (c) Datalogics, Inc. All rights reserved. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace datalogics_interface; + +namespace { + +// ───────────────────────────────────────────────────────────────────────────── +// Command line +// ───────────────────────────────────────────────────────────────────────────── + +class CommandLineException : public std::runtime_error { +public: + explicit CommandLineException(const std::string& message) : std::runtime_error(message) {} +}; + +std::string to_lower(const std::string& s) +{ + std::string out = s; + for (char& c : out) { + if (c >= 'A' && c <= 'Z') + c = static_cast(c - 'A' + 'a'); + } + return out; +} + +struct CommandLineOptions { + std::string metadata_path = "data/metadata.json"; + std::string line_items_path = "data/line-items.csv"; + std::string style_path = "data/style.json"; + std::string output_path = "CreateInvoiceFromStructuredData-out.pdf"; +}; + +void print_usage() +{ + std::cout << + "Usage:\n" + " create_invoice_from_structured_data [options]\n" + "\n" + "With no options the sample reads data/metadata.json, data/line-items.csv,\n" + "and data/style.json, then writes CreateInvoiceFromStructuredData-out.pdf.\n" + "\n" + "Options:\n" + " --metadata JSON file with seller, customer, and invoice metadata.\n" + " --line-items CSV file with invoice line items.\n" + " --style JSON file with fonts, colors, and page dimensions.\n" + " --output Output PDF path.\n" + " --self-test Validate parsing and totals without creating a PDF.\n" + " --help Show this help.\n" + "\n" + "Set APDFL_LICENSE_KEY to provide a Datalogics APDFL activation key before\n" + "Library initialization.\n"; +} + +CommandLineOptions parse_options(const std::vector& args) +{ + CommandLineOptions options; + auto require_value = [&](size_t& i, const std::string& option) -> const std::string& { + if (i + 1 >= args.size()) + throw CommandLineException(option + " requires a value."); + return args[++i]; + }; + for (size_t i = 0; i < args.size(); ++i) { + const std::string lower = to_lower(args[i]); + if (lower == "--metadata" || lower == "--invoice") + options.metadata_path = require_value(i, args[i]); + else if (lower == "--line-items") + options.line_items_path = require_value(i, args[i]); + else if (lower == "--style") + options.style_path = require_value(i, args[i]); + else if (lower == "--output") + options.output_path = require_value(i, args[i]); + else + throw CommandLineException("Unknown option: " + args[i]); + } + return options; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Minimal JSON parser — just enough for the sample's flat metadata and style +// files: objects, arrays, strings, numbers, booleans, and null. +// ───────────────────────────────────────────────────────────────────────────── + +class JsonValue { +public: + enum class Type { Null, Boolean, Number, String, Object, Array }; + + Type type = Type::Null; + bool boolean = false; + double number = 0; + std::string string; + std::map object; + std::vector array; + + const JsonValue* find(const std::string& key) const + { + // Keys are matched case-insensitively, like the .NET sample. + for (const auto& [name, value] : object) { + if (to_lower(name) == to_lower(key)) + return &value; + } + return nullptr; + } + + std::string get_string(const std::string& key, const std::string& fallback = "") const + { + const JsonValue* value = find(key); + return value && value->type == Type::String ? value->string : fallback; + } + + double get_number(const std::string& key, double fallback = 0) const + { + const JsonValue* value = find(key); + return value && value->type == Type::Number ? value->number : fallback; + } + + bool get_boolean(const std::string& key, bool fallback = false) const + { + const JsonValue* value = find(key); + return value && value->type == Type::Boolean ? value->boolean : fallback; + } +}; + +class JsonParser { +public: + static JsonValue parse(const std::string& text) + { + JsonParser parser(text); + JsonValue value = parser.parse_value(); + parser.skip_whitespace(); + if (parser.pos_ != text.size()) + throw std::invalid_argument("Unexpected trailing content in JSON input."); + return value; + } + +private: + explicit JsonParser(const std::string& text) : text_(text) {} + + const std::string& text_; + size_t pos_ = 0; + + [[noreturn]] void fail(const std::string& message) const + { + throw std::invalid_argument("JSON error at offset " + std::to_string(pos_) + ": " + + message); + } + + void skip_whitespace() + { + while (pos_ < text_.size()) { + const char c = text_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + ++pos_; + } else if (c == '/' && pos_ + 1 < text_.size() && text_[pos_ + 1] == '/') { + while (pos_ < text_.size() && text_[pos_] != '\n') + ++pos_; + } else { + break; + } + } + } + + char peek() + { + skip_whitespace(); + if (pos_ >= text_.size()) + fail("unexpected end of input"); + return text_[pos_]; + } + + void expect(char c) + { + if (peek() != c) + fail(std::string("expected '") + c + "'"); + ++pos_; + } + + JsonValue parse_value() + { + const char c = peek(); + if (c == '{') + return parse_object(); + if (c == '[') + return parse_array(); + if (c == '"') + return parse_string_value(); + if (c == 't' || c == 'f') { + JsonValue value; + value.type = JsonValue::Type::Boolean; + value.boolean = c == 't'; + expect_literal(value.boolean ? "true" : "false"); + return value; + } + if (c == 'n') { + expect_literal("null"); + return JsonValue{}; + } + return parse_number(); + } + + void expect_literal(const std::string& literal) + { + skip_whitespace(); + if (text_.compare(pos_, literal.size(), literal) != 0) + fail("expected \"" + literal + "\""); + pos_ += literal.size(); + } + + JsonValue parse_object() + { + JsonValue value; + value.type = JsonValue::Type::Object; + expect('{'); + if (peek() == '}') { + ++pos_; + return value; + } + while (true) { + if (peek() == '}') { // trailing comma + ++pos_; + return value; + } + const std::string key = parse_string(); + expect(':'); + value.object[key] = parse_value(); + if (peek() == ',') { + ++pos_; + continue; + } + expect('}'); + return value; + } + } + + JsonValue parse_array() + { + JsonValue value; + value.type = JsonValue::Type::Array; + expect('['); + if (peek() == ']') { + ++pos_; + return value; + } + while (true) { + if (peek() == ']') { // trailing comma + ++pos_; + return value; + } + value.array.push_back(parse_value()); + if (peek() == ',') { + ++pos_; + continue; + } + expect(']'); + return value; + } + } + + JsonValue parse_string_value() + { + JsonValue value; + value.type = JsonValue::Type::String; + value.string = parse_string(); + return value; + } + + std::string parse_string() + { + expect('"'); + std::string out; + while (pos_ < text_.size()) { + const char c = text_[pos_++]; + if (c == '"') + return out; + if (c != '\\') { + out.push_back(c); + continue; + } + if (pos_ >= text_.size()) + fail("unterminated escape"); + const char escape = text_[pos_++]; + switch (escape) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + if (pos_ + 4 > text_.size()) + fail("truncated \\u escape"); + const unsigned int cp = + static_cast(std::stoul(text_.substr(pos_, 4), nullptr, 16)); + pos_ += 4; + // Encode the basic multilingual plane code point as UTF-8; + // surrogate pairs are not needed by the sample inputs. + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + break; + } + default: fail("unsupported escape"); + } + } + fail("unterminated string"); + } + + JsonValue parse_number() + { + skip_whitespace(); + const size_t start = pos_; + while (pos_ < text_.size()) { + const char c = text_[pos_]; + if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == 'e' || + c == 'E') + ++pos_; + else + break; + } + if (pos_ == start) + fail("expected a value"); + JsonValue value; + value.type = JsonValue::Type::Number; + std::istringstream stream(text_.substr(start, pos_ - start)); + stream.imbue(std::locale::classic()); + if (!(stream >> value.number)) + fail("invalid number"); + return value; + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Invoice model +// ───────────────────────────────────────────────────────────────────────────── + +struct CompanyInfo { + std::string name; + std::string tax_id; + std::string address_line1; + std::string address_line2; + std::string city; + std::string region; + std::string postal_code; + std::string country; + std::string email; + std::string phone; + + std::vector address_lines() const + { + std::string locality = city; + if (!region.empty()) + locality += (locality.empty() ? "" : ", ") + region; + if (!postal_code.empty()) + locality += (locality.empty() ? "" : " ") + postal_code; + std::vector lines{address_line1, address_line2, locality, country}; + lines.erase(std::remove_if(lines.begin(), lines.end(), + [](const std::string& line) { return line.empty(); }), + lines.end()); + return lines; + } +}; + +struct InvoiceInput { + std::string invoice_number; + std::string issue_date; + std::string due_date; + std::string currency = "USD"; + double tax_rate = 0; + std::string logo_path; + std::string payment_terms; + std::string notes; + bool apply_restriction_password = true; + std::string restriction_password = "NSS-Restrict-2026-ReviewOnly!"; + CompanyInfo seller; + CompanyInfo customer; +}; + +struct InvoiceLineItem { + std::string item_code; + std::string description; + double quantity = 0; + double unit_price = 0; + + double amount() const { return quantity * unit_price; } +}; + +struct RgbColor { + double r = 0, g = 0, b = 0; + + static RgbColor from_hex(const std::string& value) + { + std::string hex = value; + if (!hex.empty() && hex[0] == '#') + hex.erase(0, 1); + const bool valid = hex.size() == 6 && + hex.find_first_not_of("0123456789abcdefABCDEF") == std::string::npos; + if (!valid) + throw std::invalid_argument("Color value must be in #RRGGBB form: " + value); + auto channel = [&](size_t at) { + return static_cast(std::stoul(hex.substr(at, 2), nullptr, 16)) / 255.0; + }; + return {channel(0), channel(2), channel(4)}; + } + + Color to_color() const { return Color(r, g, b); } +}; + +struct InvoiceStyle { + std::string body_font = "Helvetica"; + std::string bold_font = "Helvetica-Bold"; + double body_font_size = 9.5; + double small_font_size = 8.0; + double heading_font_size = 20.0; + double table_header_font_size = 8.5; + double page_width = 612.0; + double page_height = 792.0; + double margin = 54.0; + double logo_max_width = 172.0; + double logo_max_height = 54.0; + RgbColor primary_color = RgbColor::from_hex("#24536A"); + RgbColor accent_color = RgbColor::from_hex("#E8F1F4"); + RgbColor text_color = RgbColor::from_hex("#222222"); + RgbColor muted_text_color = RgbColor::from_hex("#666666"); + RgbColor border_color = RgbColor::from_hex("#B8C7CE"); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Input loading +// ───────────────────────────────────────────────────────────────────────────── + +std::string read_file(const std::string& path) +{ + std::ifstream input(path, std::ios::binary); + if (!input) + throw std::invalid_argument("File was not found: " + path); + std::stringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); +} + +CompanyInfo load_company(const JsonValue& root, const std::string& key) +{ + CompanyInfo company; + const JsonValue* value = root.find(key); + if (!value) + return company; + company.name = value->get_string("name"); + company.tax_id = value->get_string("taxId"); + company.address_line1 = value->get_string("addressLine1"); + company.address_line2 = value->get_string("addressLine2"); + company.city = value->get_string("city"); + company.region = value->get_string("region"); + company.postal_code = value->get_string("postalCode"); + company.country = value->get_string("country"); + company.email = value->get_string("email"); + company.phone = value->get_string("phone"); + return company; +} + +InvoiceInput load_invoice(const std::string& path) +{ + const JsonValue root = JsonParser::parse(read_file(path)); + InvoiceInput invoice; + invoice.invoice_number = root.get_string("invoiceNumber"); + invoice.issue_date = root.get_string("issueDate"); + invoice.due_date = root.get_string("dueDate"); + invoice.currency = root.get_string("currency", "USD"); + invoice.tax_rate = root.get_number("taxRate", 0); + invoice.logo_path = root.get_string("logoPath"); + invoice.payment_terms = root.get_string("paymentTerms"); + invoice.notes = root.get_string("notes"); + invoice.apply_restriction_password = root.get_boolean("applyRestrictionPassword", true); + invoice.restriction_password = + root.get_string("restrictionPassword", invoice.restriction_password); + invoice.seller = load_company(root, "seller"); + invoice.customer = load_company(root, "customer"); + + if (invoice.invoice_number.empty()) + throw std::invalid_argument("Invoice metadata requires a non-empty invoiceNumber."); + if (invoice.seller.name.empty()) + throw std::invalid_argument("Invoice metadata requires a seller name."); + if (invoice.customer.name.empty()) + throw std::invalid_argument("Invoice metadata requires a customer name."); + if (invoice.apply_restriction_password && invoice.restriction_password.empty()) + throw std::invalid_argument( + "restrictionPassword must be set when applyRestrictionPassword is true."); + return invoice; +} + +// Splits one CSV row on commas, honoring double-quoted fields and "" escapes. +std::vector parse_csv_row(const std::string& row) +{ + std::vector fields; + std::string current; + bool quoted = false; + for (size_t i = 0; i < row.size(); ++i) { + const char c = row[i]; + if (quoted) { + if (c == '"' && i + 1 < row.size() && row[i + 1] == '"') { + current.push_back('"'); + ++i; + } else if (c == '"') { + quoted = false; + } else { + current.push_back(c); + } + } else if (c == '"') { + quoted = true; + } else if (c == ',') { + fields.push_back(current); + current.clear(); + } else { + current.push_back(c); + } + } + fields.push_back(current); + for (auto& field : fields) { + const auto begin = field.find_first_not_of(" \t\r"); + const auto end = field.find_last_not_of(" \t\r"); + field = begin == std::string::npos ? "" : field.substr(begin, end - begin + 1); + } + return fields; +} + +double parse_decimal(const std::string& value, int row_number, const std::string& column) +{ + // Invariant parse: '.' decimal separator, thousands separators allowed. + std::string cleaned; + for (char c : value) { + if (c != ',') + cleaned.push_back(c); + } + std::istringstream stream(cleaned); + stream.imbue(std::locale::classic()); + double out = 0; + if (!(stream >> out) || !(stream >> std::ws).eof()) + throw std::invalid_argument("Row " + std::to_string(row_number) + " has an invalid " + + column + " value: " + value); + return out; +} + +std::vector load_line_items(const std::string& path) +{ + std::stringstream stream(read_file(path)); + std::vector rows; + std::string line; + while (std::getline(stream, line)) { + if (line.find_first_not_of(" \t\r") != std::string::npos) + rows.push_back(line); + } + if (rows.size() < 2) + throw std::invalid_argument("Line item CSV requires a header row and at least one item."); + + std::map columns; + const std::vector header = parse_csv_row(rows[0]); + for (size_t i = 0; i < header.size(); ++i) + columns[to_lower(header[i])] = i; + for (const char* required : {"itemcode", "description", "quantity", "unitprice"}) { + if (!columns.count(required)) + throw std::invalid_argument(std::string("Line item CSV is missing the ") + required + + " column."); + } + + std::vector items; + for (size_t r = 1; r < rows.size(); ++r) { + const std::vector fields = parse_csv_row(rows[r]); + auto field = [&](const char* name) { + const size_t index = columns.at(name); + return index < fields.size() ? fields[index] : std::string(); + }; + InvoiceLineItem item; + item.item_code = field("itemcode"); + item.description = field("description"); + const int row_number = static_cast(r) + 1; + item.quantity = parse_decimal(field("quantity"), row_number, "quantity"); + item.unit_price = parse_decimal(field("unitprice"), row_number, "unitPrice"); + items.push_back(std::move(item)); + } + return items; +} + +InvoiceStyle load_style(const std::string& path) +{ + const JsonValue root = JsonParser::parse(read_file(path)); + InvoiceStyle style; + style.body_font = root.get_string("bodyFont", style.body_font); + style.bold_font = root.get_string("boldFont", style.bold_font); + style.body_font_size = root.get_number("bodyFontSize", style.body_font_size); + style.small_font_size = root.get_number("smallFontSize", style.small_font_size); + style.heading_font_size = root.get_number("headingFontSize", style.heading_font_size); + style.table_header_font_size = + root.get_number("tableHeaderFontSize", style.table_header_font_size); + style.page_width = root.get_number("pageWidth", style.page_width); + style.page_height = root.get_number("pageHeight", style.page_height); + style.margin = root.get_number("margin", style.margin); + style.logo_max_width = root.get_number("logoMaxWidth", style.logo_max_width); + style.logo_max_height = root.get_number("logoMaxHeight", style.logo_max_height); + auto color = [&](const char* key, RgbColor fallback) { + const JsonValue* value = root.find(key); + return value && value->type == JsonValue::Type::String ? RgbColor::from_hex(value->string) + : fallback; + }; + style.primary_color = color("primaryColor", style.primary_color); + style.accent_color = color("accentColor", style.accent_color); + style.text_color = color("textColor", style.text_color); + style.muted_text_color = color("mutedTextColor", style.muted_text_color); + style.border_color = color("borderColor", style.border_color); + return style; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Formatting (invariant, US-style separators to match the .NET sample) +// ───────────────────────────────────────────────────────────────────────────── + +double round_away_from_zero(double value, int decimals) +{ + const double scale = std::pow(10.0, decimals); + return (value >= 0 ? std::floor(value * scale + 0.5) : std::ceil(value * scale - 0.5)) / + scale; +} + +std::string format_fixed(double value, int decimals, bool thousands) +{ + std::ostringstream stream; + stream.imbue(std::locale::classic()); + stream << std::fixed << std::setprecision(decimals) << std::abs(value); + std::string digits = stream.str(); + if (thousands) { + const size_t point = digits.find('.'); + size_t whole_end = point == std::string::npos ? digits.size() : point; + for (size_t i = whole_end; i > 3; i -= 3) + digits.insert(i - 3, ","); + } + return (value < 0 ? "-" : "") + digits; +} + +// "0.##": up to two decimals, no trailing zeros. +std::string format_number(double value) +{ + std::string out = format_fixed(round_away_from_zero(value, 2), 2, false); + while (!out.empty() && out.back() == '0') + out.pop_back(); + if (!out.empty() && out.back() == '.') + out.pop_back(); + return out; +} + +std::string format_currency(double value, const std::string& currency) +{ + const std::string amount = format_fixed(value, 2, true); + if (to_lower(currency) == "usd") + return "$" + amount; + return currency + " " + amount; +} + +std::string format_percent(double rate) +{ + return format_number(rate * 100.0) + "%"; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Invoice renderer +// ───────────────────────────────────────────────────────────────────────────── + +class InvoiceRenderer { +public: + InvoiceRenderer(Document& document, const InvoiceStyle& style) + : document_(document), style_(style) + { + body_font_ = create_font(style.body_font); + bold_font_ = create_font(style.bold_font); + + root_ = std::make_unique(document.create_struct_tree_root()); + document_element_ = std::make_unique(root_->add_child("Document")); + + PDFString lang("en-US", document_, false); + document_.get_root()->put("Lang", lang); + } + + void render(const InvoiceInput& invoice, const std::vector& items, + const std::string& base_directory) + { + invoice_ = &invoice; + new_page(); + render_header(base_directory); + render_parties(); + render_line_items(items); + render_totals(items); + render_notes(); + page_->update_content(); + } + + int page_count() const { return page_count_; } + + static double subtotal(const std::vector& items) + { + double total = 0; + for (const auto& item : items) + total += item.amount(); + return total; + } + + static double tax(const std::vector& items, double rate) + { + return round_away_from_zero(subtotal(items) * rate, 2); + } + +private: + Document& document_; + const InvoiceStyle& style_; + const InvoiceInput* invoice_ = nullptr; + std::unique_ptr body_font_; + std::unique_ptr bold_font_; + std::unique_ptr root_; + std::unique_ptr document_element_; + + std::optional page_; + std::optional content_; + double cursor_y_ = 0; + int page_count_ = 0; + + // ── Fonts / pages / tagging ─────────────────────────────── + + static std::unique_ptr create_font(const std::string& name) + { + for (FontCreateFlags flags : + {FontCreateFlags::Embedded | FontCreateFlags::Subset, FontCreateFlags::Subset}) { + try { + return std::make_unique(name, flags); + } catch (const std::exception&) { + continue; + } + } + throw std::invalid_argument("Could not create font \"" + name + + "\". Adjust bodyFont/boldFont in the style file."); + } + + void new_page() + { + if (page_) + page_->update_content(); + const int insert_after = page_count_ == 0 ? Document::before_first_page : page_count_ - 1; + page_.emplace(document_.create_page(insert_after, + Rect{0, 0, style_.page_width, style_.page_height})); + content_.emplace(page_->get_content()); + cursor_y_ = style_.page_height - style_.margin; + ++page_count_; + } + + void add_tagged(Element& element, const std::string& tag, StructElement& owner) + { + Content inner; + inner.add_element(element); + PDFDict props(document_, false); // empty: the MCID is assigned by the library + Container container(tag, props, false); + container.set_content(inner); + content_->add_element(container); + owner.add_marked_content_ref(*page_, container); + } + + void add_artifact(Element& element) + { + Content inner; + inner.add_element(element); + PDFDict props(document_, false); + PDFName layout("Layout", document_, false); + props.put("Type", layout); + Container container("Artifact", props, false); + container.set_content(inner); + content_->add_element(container); + } + + // ── Drawing primitives ──────────────────────────────────── + + double measure(const std::string& text, Font& font, double size) const + { + try { + return font.measure_text_width(text, size); + } catch (const std::exception&) { + return static_cast(text.size()) * size * 0.55; + } + } + + void draw_text(const std::string& value, Font& font, double size, double x, double y, + const RgbColor& color, StructElement* owner, const std::string& tag) + { + if (value.empty()) + return; + GraphicState gs; + gs.set_fill_color(color.to_color()); + TextState ts; + Text text; + Matrix matrix(size, 0, 0, size, x, y); + TextRun run(value, font, gs, ts, matrix); + text.add_run(run); + if (owner) + add_tagged(text, tag, *owner); + else + add_artifact(text); + } + + void draw_right_aligned_text(const std::string& value, Font& font, double size, + double right_x, double y, const RgbColor& color, + StructElement* owner, const std::string& tag) + { + draw_text(value, font, size, right_x - measure(value, font, size), y, color, owner, tag); + } + + void draw_box(double x, double top_y, double width, double height, const RgbColor& fill, + const RgbColor& border) + { + Path box; + GraphicState gs; + gs.set_fill_color(fill.to_color()); + gs.set_stroke_color(border.to_color()); + gs.set_width(0.5); + box.set_graphic_state(gs); + box.set_paint_op(PathPaintOp::Fill | PathPaintOp::Stroke); + box.add_rect(Point(x, top_y - height), width, height); + add_artifact(box); + } + + void draw_rule(double x1, double x2, double y, double width = 0.75) + { + Path line; + GraphicState gs; + gs.set_stroke_color(style_.border_color.to_color()); + gs.set_width(width); + line.set_graphic_state(gs); + line.set_paint_op(PathPaintOp::Stroke); + line.move_to(Point(x1, y)); + line.add_line(Point(x2, y)); + add_artifact(line); + } + + std::vector wrap_text(const std::string& text, Font& font, double size, + double max_width) const + { + std::vector lines; + std::istringstream stream(text); + std::string word, current; + while (stream >> word) { + const std::string candidate = current.empty() ? word : current + " " + word; + if (!current.empty() && measure(candidate, font, size) > max_width) { + lines.push_back(current); + current = word; + } else { + current = candidate; + } + } + if (!current.empty()) + lines.push_back(current); + if (lines.empty()) + lines.push_back(""); + return lines; + } + + // Starts a continuation page when less than `needed_height` remains. + bool ensure_room(double needed_height) + { + if (cursor_y_ - needed_height >= style_.margin) + return false; + new_page(); + StructElement sect = document_element_->add_child("Sect"); + draw_text("Invoice continued", *bold_font_, 11, style_.margin, cursor_y_, + style_.primary_color, §, "P"); + draw_rule(style_.margin, style_.page_width - style_.margin, cursor_y_ - 12); + draw_footer(page_count_); + cursor_y_ -= 36; + return true; + } + + void draw_footer(int page_number) + { + draw_rule(style_.margin, style_.page_width - style_.margin, style_.margin - 14); + const double baseline = style_.margin - 31; + const double size = 7.2; + const CompanyInfo& seller = invoice_->seller; + draw_text(seller.name, *body_font_, size, style_.margin, baseline, + style_.muted_text_color, nullptr, ""); + const std::string center = seller.email + " | " + seller.phone; + draw_text(center, *body_font_, size, + (style_.page_width - measure(center, *body_font_, size)) / 2, baseline, + style_.muted_text_color, nullptr, ""); + const std::string page_label = "Page " + std::to_string(page_number); + draw_text(page_label, *body_font_, size, + style_.page_width - style_.margin - measure(page_label, *body_font_, size), + baseline, style_.muted_text_color, nullptr, ""); + } + + // ── Sections ────────────────────────────────────────────── + + void render_header(const std::string& base_directory) + { + StructElement sect = document_element_->add_child("Sect"); + + namespace fs = std::filesystem; + if (!invoice_->logo_path.empty()) { + const fs::path logo = fs::path(base_directory) / invoice_->logo_path; + if (fs::exists(logo)) + draw_logo(logo.string(), sect); + } + + const double right_x = style_.page_width - style_.margin; + const double top_y = style_.page_height - style_.margin; + StructElement heading = sect.add_child("H1"); + draw_right_aligned_text("INVOICE", *bold_font_, style_.heading_font_size, right_x, + top_y - 10, style_.primary_color, &heading, "H1"); + StructElement details = sect.add_child("P"); + draw_right_aligned_text("Invoice " + invoice_->invoice_number, *body_font_, 11, right_x, + top_y - 34, style_.text_color, &details, "P"); + draw_right_aligned_text("Issued " + invoice_->issue_date, *body_font_, 9, right_x, + top_y - 50, style_.muted_text_color, &details, "P"); + draw_right_aligned_text("Due " + invoice_->due_date, *body_font_, 9, right_x, top_y - 64, + style_.muted_text_color, &details, "P"); + + draw_rule(style_.margin, right_x, top_y - 82); + draw_footer(1); + cursor_y_ = top_y - 112; + } + + void draw_logo(const std::string& path, StructElement& sect) + { + Image logo(path, document_); + Matrix design = logo.get_matrix(); + const double scale = + std::min(style_.logo_max_width / design.a, style_.logo_max_height / design.d); + logo.scale(scale, scale); + logo.translate(style_.margin, + style_.page_height - style_.margin - style_.logo_max_height); + + StructElement figure = sect.add_child("Figure"); + Content inner; + inner.add_element(logo); + PDFDict props(document_, false); + Container container("Figure", props, false); + container.set_content(inner); + content_->add_element(container); + figure.add_marked_content_ref(*page_, container); + + PDFString alt("Seller logo", document_, false); + figure.get_pdf_dict().put("Alt", alt); + } + + void render_parties() + { + StructElement sect = document_element_->add_child("Sect"); + const double gutter = 18; + const double column_width = (style_.page_width - 2 * style_.margin - gutter) / 2; + const double block_height = 170; + const double start_y = cursor_y_ + 6; + + draw_party_block(sect, "From", invoice_->seller, style_.margin, start_y, column_width, + block_height); + draw_party_block(sect, "Bill To", invoice_->customer, + style_.margin + column_width + gutter, start_y, column_width, + block_height); + cursor_y_ = start_y - block_height - 24; + } + + void draw_party_block(StructElement& sect, const std::string& label, + const CompanyInfo& company, double x, double top_y, double width, + double height) + { + StructElement block = sect.add_child("P"); + draw_box(x, top_y, width, height, style_.accent_color, style_.border_color); + const double inset = 14; + const double text_x = x + inset; + + draw_text(label, *bold_font_, 9, text_x, top_y - 18, style_.primary_color, &block, "P"); + draw_text(company.name, *bold_font_, 12, text_x, top_y - 42, style_.text_color, &block, + "P"); + if (!company.tax_id.empty()) + draw_text(company.tax_id, *body_font_, 9, text_x, top_y - 62, + style_.muted_text_color, &block, "P"); + + double current_y = top_y - 86; + for (const auto& line : company.address_lines()) { + draw_text(line, *body_font_, 9, text_x, current_y, style_.text_color, &block, "P"); + current_y -= 13; + } + draw_text(company.email, *body_font_, 9, text_x, current_y - 4, style_.text_color, + &block, "P"); + draw_text(company.phone, *body_font_, 9, text_x, current_y - 21, style_.text_color, + &block, "P"); + } + + struct TableLayout { + std::vector widths{72, 234, 54, 72, 72}; + std::vector headers{"Item", "Description", "Qty", "Unit Price", "Amount"}; + + double total_width() const + { + double total = 0; + for (double w : widths) + total += w; + return total; + } + }; + + void draw_table_header(const TableLayout& layout, StructElement& table) + { + StructElement row = table.add_child("TR"); + const double height = 22; + Path box; + GraphicState gs; + gs.set_fill_color(style_.primary_color.to_color()); + gs.set_stroke_color(style_.primary_color.to_color()); + gs.set_width(0.5); + box.set_graphic_state(gs); + box.set_paint_op(PathPaintOp::Fill | PathPaintOp::Stroke); + box.add_rect(Point(style_.margin, cursor_y_ + 5 - height), layout.total_width(), height); + add_artifact(box); + + double x = style_.margin; + const RgbColor white{1, 1, 1}; + for (size_t c = 0; c < layout.headers.size(); ++c) { + StructElement cell = row.add_child("TH"); + draw_text(layout.headers[c], *bold_font_, style_.table_header_font_size, x + 6, + cursor_y_ - 9, white, &cell, "TH"); + x += layout.widths[c]; + } + cursor_y_ -= height; + } + + void draw_line_item_row(const TableLayout& layout, StructElement& table, + const InvoiceLineItem& item) + { + const double description_width = layout.widths[1] - 12; + const std::vector description_lines = + wrap_text(item.description, *body_font_, style_.body_font_size, description_width); + const double row_height = + std::max(26.0, static_cast(description_lines.size()) * 12.0 + 12.0); + + if (ensure_room(row_height + 44)) + draw_table_header(layout, table); + + StructElement row = table.add_child("TR"); + const RgbColor white{1, 1, 1}; + draw_box(style_.margin, cursor_y_, layout.total_width(), row_height, white, + style_.border_color); + + const double text_top = cursor_y_ - 16; + double x = style_.margin; + auto draw_cell = [&](size_t column, const std::string& value, Font& font, + bool right_aligned) { + StructElement cell = row.add_child("TD"); + if (right_aligned) { + draw_right_aligned_text(value, font, style_.body_font_size, + x + layout.widths[column] - 6, text_top, + style_.text_color, &cell, "TD"); + } else { + draw_text(value, font, style_.body_font_size, x + 6, text_top, + style_.text_color, &cell, "TD"); + } + x += layout.widths[column]; + }; + + draw_cell(0, item.item_code, *body_font_, false); + { + StructElement cell = row.add_child("TD"); + double line_y = text_top; + for (const auto& line : description_lines) { + draw_text(line, *body_font_, style_.body_font_size, x + 6, line_y, + style_.text_color, &cell, "TD"); + line_y -= 12; + } + x += layout.widths[1]; + } + draw_cell(2, format_number(item.quantity), *body_font_, true); + draw_cell(3, format_currency(item.unit_price, invoice_->currency), *body_font_, true); + draw_cell(4, format_currency(item.amount(), invoice_->currency), *bold_font_, true); + + cursor_y_ -= row_height; + } + + void render_line_items(const std::vector& items) + { + ensure_room(120); + StructElement table = document_element_->add_child("Table"); + TableLayout layout; + draw_table_header(layout, table); + for (const auto& item : items) + draw_line_item_row(layout, table, item); + } + + void render_totals(const std::vector& items) + { + ensure_room(132); + StructElement sect = document_element_->add_child("Sect"); + + const double box_width = 214; + const double box_height = 92; + const double inset = 14; + const double box_x = style_.page_width - style_.margin - box_width; + const double box_top = cursor_y_ - 6; + draw_box(box_x, box_top, box_width, box_height, style_.accent_color, + style_.border_color); + + const double label_x = box_x + inset; + const double value_right = box_x + box_width - inset; + const double sub = subtotal(items); + const double tax_amount = tax(items, invoice_->tax_rate); + + double y = box_top - 24; + auto total_line = [&](const std::string& label, const std::string& value, Font& font, + double size) { + StructElement line = sect.add_child("P"); + draw_text(label, font, size, label_x, y, style_.text_color, &line, "P"); + draw_right_aligned_text(value, font, size, value_right, y, style_.text_color, &line, + "P"); + }; + total_line("Subtotal", format_currency(sub, invoice_->currency), *body_font_, + style_.body_font_size); + y -= 20; + total_line("Tax (" + format_percent(invoice_->tax_rate) + ")", + format_currency(tax_amount, invoice_->currency), *body_font_, + style_.body_font_size); + y -= 16; + draw_rule(label_x, value_right, y); + y -= 20; + total_line("Total", format_currency(sub + tax_amount, invoice_->currency), *bold_font_, + 12); + + cursor_y_ = box_top - box_height - 28; + } + + void draw_paragraph(const std::string& text, StructElement& owner) + { + const double width = style_.page_width - 2 * style_.margin; + for (const auto& line : wrap_text(text, *body_font_, style_.body_font_size, width)) { + ensure_room(16); + draw_text(line, *body_font_, style_.body_font_size, style_.margin, cursor_y_, + style_.text_color, &owner, "P"); + cursor_y_ -= 13; + } + } + + void render_notes() + { + ensure_room(96); + StructElement sect = document_element_->add_child("Sect"); + + StructElement terms_heading = sect.add_child("H2"); + draw_text("Payment Terms", *bold_font_, 11, style_.margin, cursor_y_, + style_.primary_color, &terms_heading, "H2"); + cursor_y_ -= 16; + StructElement terms = sect.add_child("P"); + draw_paragraph(invoice_->payment_terms, terms); + + cursor_y_ -= 10; + StructElement notes_heading = sect.add_child("H2"); + draw_text("Notes", *bold_font_, 11, style_.margin, cursor_y_, style_.primary_color, + ¬es_heading, "H2"); + cursor_y_ -= 16; + StructElement notes = sect.add_child("P"); + draw_paragraph(invoice_->notes, notes); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Self-tests: validate parsing and totals against the bundled data files. +// ───────────────────────────────────────────────────────────────────────────── + +int run_self_tests() +{ + int failures = 0; + auto require = [&](bool condition, const std::string& what) { + if (!condition) { + std::cout << "FAILED: " << what << std::endl; + ++failures; + } + }; + + try { + const auto fields = parse_csv_row( + "\"FIELD-102\",\"Quarterly garden planning, includes layout notes\",2,125.50"); + require(fields.size() == 4, "CSV row field count"); + require(fields[1] == "Quarterly garden planning, includes layout notes", + "quoted comma preserved"); + + const CommandLineOptions defaults = parse_options({}); + require(defaults.metadata_path == "data/metadata.json", "default metadata path"); + require(defaults.line_items_path == "data/line-items.csv", "default line items path"); + require(defaults.style_path == "data/style.json", "default style path"); + + const InvoiceInput invoice = load_invoice(defaults.metadata_path); + const std::vector items = load_line_items(defaults.line_items_path); + const InvoiceStyle style = load_style(defaults.style_path); + + require(!invoice.logo_path.empty(), "logo path present"); + require(invoice.apply_restriction_password && !invoice.restriction_password.empty(), + "restriction password enabled"); + namespace fs = std::filesystem; + const fs::path base = fs::absolute(defaults.metadata_path).parent_path(); + require(fs::exists(base / invoice.logo_path), "logo file exists"); + require(items.size() >= 20, "at least 20 line items"); + + const double sub = InvoiceRenderer::subtotal(items); + const double tax = InvoiceRenderer::tax(items, invoice.tax_rate); + require(sub > 0, "subtotal positive"); + require(tax > 0, "tax positive"); + require(style.page_width > 0 && style.page_height > 0, "style page size positive"); + + require(format_currency(1234.5, "USD") == "$1,234.50", "currency formatting"); + require(format_percent(0.0825) == "8.25%", "percent formatting"); + require(format_number(2.0) == "2", "quantity formatting"); + } catch (const std::exception& e) { + std::cout << "FAILED: " << e.what() << std::endl; + ++failures; + } + + if (failures == 0) { + std::cout << "Self-tests passed." << std::endl; + return 0; + } + std::cout << failures << " self-test(s) failed." << std::endl; + return 1; +} + +} // namespace + +// ───────────────────────────────────────────────────────────────────────────── +// main +// ───────────────────────────────────────────────────────────────────────────── + +int main(int argc, char* argv[]) +{ + std::cout << "CreateInvoiceFromStructuredData Sample:" << std::endl; + + std::vector args(argv + 1, argv + argc); + for (const auto& arg : args) { + const std::string lower = to_lower(arg); + if (lower == "--help" || lower == "-h" || lower == "/?") { + print_usage(); + return 0; + } + } + + try { + if (const char* key = std::getenv("APDFL_LICENSE_KEY"); key && *key) + Library::set_license_key(key); + + Library library; + + if (args.size() == 1 && to_lower(args[0]) == "--self-test") + return run_self_tests(); + + const CommandLineOptions options = parse_options(args); + const InvoiceInput invoice = load_invoice(options.metadata_path); + const std::vector items = load_line_items(options.line_items_path); + const InvoiceStyle style = load_style(options.style_path); + + namespace fs = std::filesystem; + const std::string base_directory = + fs::absolute(options.metadata_path).parent_path().string(); + + Document document; + document.set_title("Invoice " + invoice.invoice_number); + document.set_producer("CreateInvoiceFromStructuredData sample using Datalogics APDFL"); + + InvoiceRenderer renderer(document, style); + renderer.render(invoice, items, base_directory); + + document.embed_fonts(); + + if (invoice.apply_restriction_password) { + // Viewing, printing, copying, and accessibility stay available; + // editing requires the owner password. + document.secure(PermissionFlags::Open | PermissionFlags::Print | + PermissionFlags::HighPrint | PermissionFlags::Copy | + PermissionFlags::Accessible | PermissionFlags::SaveAs, + invoice.restriction_password, "", EncryptionType::AES256_AcroX, + true); + } + + document.save(SaveFlags::Full, options.output_path); + std::cout << "Created tagged invoice PDF: " << options.output_path << std::endl; + } catch (const CommandLineException& e) { + std::cout << "ERROR: " << e.what() << std::endl; + std::cout << "Run with --help for usage." << std::endl; + return 2; + } catch (const std::exception& e) { + std::cout << "ERROR: " << e.what() << std::endl; + return 1; + } + return 0; +} diff --git a/DocumentConversion/CreateInvoiceFromStructuredData/data/line-items.csv b/DocumentConversion/CreateInvoiceFromStructuredData/data/line-items.csv new file mode 100644 index 0000000..94f2eaa --- /dev/null +++ b/DocumentConversion/CreateInvoiceFromStructuredData/data/line-items.csv @@ -0,0 +1,21 @@ +itemCode,description,quantity,unitPrice +PLAN-100,"Seasonal site planning workshop for community maintenance team",1,850.00 +FIELD-210,"Field guide drafting for entrance signage, path notes, and accessibility reminders",4,175.00 +MAP-115,"Illustrated reference map preparation using supplied fictional location data",2,240.00 +OPS-305,"Operations checklist review and formatting for monthly volunteer handoff",3,135.00 +TRAIN-250,"Remote training session for invoice recipient staff",2,225.00 +DOC-410,"Structured source content review and cleanup",5,96.00 +PDF-120,"Tagged PDF generation setup and verification",2,310.00 +TABLE-215,"Long table layout review for multipage invoice output",6,88.00 +QA-330,"Accessibility-oriented review of headings, tables, and reading order",3,155.00 +SUP-101,"Sample support block for customer billing questions",1,75.00 +DATA-222,"CSV line item normalization and validation",4,82.50 +STYLE-140,"Style configuration tuning for colors, fonts, and spacing",3,115.00 +IMG-118,"Local logo placement and scaling check",1,145.00 +COPY-201,"Boilerplate payment and note copy preparation",2,90.00 +ARCH-302,"Document structure planning for tagged invoice output",2,275.00 +PAG-450,"Pagination stress review with repeated line item sections",5,122.00 +TOTAL-190,"Subtotal, tax, and grand total calculation review",1,160.00 +EXPORT-275,"Final PDF save and font embedding verification",2,205.00 +HAND-410,"Reviewer handoff notes and sample usage instructions",1,130.00 +MISC-999,"Fictional miscellaneous line item included to extend the sample invoice across pages",7,64.00 diff --git a/DocumentConversion/CreateInvoiceFromStructuredData/data/metadata.json b/DocumentConversion/CreateInvoiceFromStructuredData/data/metadata.json new file mode 100644 index 0000000..7991df9 --- /dev/null +++ b/DocumentConversion/CreateInvoiceFromStructuredData/data/metadata.json @@ -0,0 +1,36 @@ +{ + "invoiceNumber": "INV-2026-0417", + "issueDate": "2026-04-17", + "dueDate": "2026-05-17", + "currency": "USD", + "taxRate": 0.0825, + "logoPath": "../images/northstar-logo.png", + "applyRestrictionPassword": true, + "restrictionPassword": "NSS-Restrict-2026-ReviewOnly!", + "paymentTerms": "Payment is due within 30 days. ACH payments may be submitted through https://payments.northstar-sample.example/invoices using routing number 021000021 and account number 10000417. Mailed remittance may be sent to Northstar Sample Supply, PO Box 41017, Port Azure, WA 98104. Please include invoice INV-2026-0417 with all payments.", + "notes": "Thank you for choosing Northstar Sample Supply. This invoice uses fictional data and is intended only to demonstrate PDF generation from structured input files.", + "seller": { + "name": "Northstar Sample Supply", + "taxId": "EIN 84-5162047", + "addressLine1": "410 Fictional Market Street", + "addressLine2": "Suite 1200", + "city": "Port Azure", + "region": "WA", + "postalCode": "98104", + "country": "USA", + "email": "billing@example.com", + "phone": "+1 206 555 0198" + }, + "customer": { + "name": "Harbor & Pine Community Works", + "taxId": "TIN 92-4418733", + "addressLine1": "88 Cedar Walk", + "addressLine2": "Accounts Payable", + "city": "Lakefield", + "region": "OR", + "postalCode": "97035", + "country": "USA", + "email": "ap@example.org", + "phone": "+1 503 555 0144" + } +} diff --git a/DocumentConversion/CreateInvoiceFromStructuredData/data/style.json b/DocumentConversion/CreateInvoiceFromStructuredData/data/style.json new file mode 100644 index 0000000..c9680d6 --- /dev/null +++ b/DocumentConversion/CreateInvoiceFromStructuredData/data/style.json @@ -0,0 +1,18 @@ +{ + "bodyFont": "Helvetica", + "boldFont": "Helvetica-Bold", + "bodyFontSize": 9.5, + "smallFontSize": 8, + "headingFontSize": 20, + "tableHeaderFontSize": 8.5, + "pageWidth": 612, + "pageHeight": 792, + "margin": 54, + "logoMaxWidth": 172, + "logoMaxHeight": 54, + "primaryColor": "#24536A", + "accentColor": "#E8F1F4", + "textColor": "#222222", + "mutedTextColor": "#666666", + "borderColor": "#B8C7CE" +} diff --git a/DocumentConversion/CreateInvoiceFromStructuredData/images/northstar-logo.png b/DocumentConversion/CreateInvoiceFromStructuredData/images/northstar-logo.png new file mode 100644 index 0000000..7cfd857 Binary files /dev/null and b/DocumentConversion/CreateInvoiceFromStructuredData/images/northstar-logo.png differ diff --git a/DocumentConversion/MarkdownToPDF/Makefile b/DocumentConversion/MarkdownToPDF/Makefile new file mode 100644 index 0000000..b1071b4 --- /dev/null +++ b/DocumentConversion/MarkdownToPDF/Makefile @@ -0,0 +1,32 @@ +UNAME := $(shell uname) + +CXX ?= $(if $(filter Darwin,$(UNAME)),clang++,g++) + +TARGET = markdown_to_pdf +SRC = markdown_to_pdf.cpp + +SDK_ROOT ?= ../../.. + +CXXFLAGS = -std=c++17 -I$(SDK_ROOT)/include +LDFLAGS = -L$(SDK_ROOT)/lib -ldatalogics_interface_api + +SDK_LIB := $(abspath $(SDK_ROOT)/lib) + +ifeq ($(UNAME),Darwin) + LDFLAGS += -Wl,-rpath,$(SDK_LIB) +else + LDFLAGS += -Wl,-rpath,$(SDK_LIB) +endif + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CXX) $(CXXFLAGS) -o $@ $< $(LDFLAGS) + +clean: + rm -f $(TARGET) + +run: $(TARGET) + ./$(TARGET) + +.PHONY: all clean run diff --git a/DocumentConversion/MarkdownToPDF/MarkdownToPDF.vcxproj b/DocumentConversion/MarkdownToPDF/MarkdownToPDF.vcxproj new file mode 100644 index 0000000..977c90a --- /dev/null +++ b/DocumentConversion/MarkdownToPDF/MarkdownToPDF.vcxproj @@ -0,0 +1,114 @@ + + + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + 17.0 + MarkdownToPDF + MarkdownToPDF + + + + Application + true + v143 + + + Application + false + v143 + true + + + Application + true + v143 + + + Application + false + v143 + true + + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + stdcpp17 + /utf-8 %(AdditionalOptions) + + + Console + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + stdcpp17 + /utf-8 %(AdditionalOptions) + + + Console + true + true + true + + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + stdcpp17 + /utf-8 %(AdditionalOptions) + + + Console + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + stdcpp17 + /utf-8 %(AdditionalOptions) + + + Console + true + true + true + + + + + + + diff --git a/DocumentConversion/MarkdownToPDF/README.md b/DocumentConversion/MarkdownToPDF/README.md new file mode 100644 index 0000000..f2179ef --- /dev/null +++ b/DocumentConversion/MarkdownToPDF/README.md @@ -0,0 +1,66 @@ +# MarkdownToPDF + +Converts a practical Markdown subset directly to a newly created, tagged PDF +document with the Datalogics C++ APDFL API. The sample parses Markdown itself, +lays text out with real font metrics (`Font::measure_text_width`), and builds +the logical structure tree (`StructTreeRoot` / `StructElement` / +`Container::add_marked_content_ref`) while it draws, so the output is a +tagged, accessible PDF — no HTML, no browser, no third-party Markdown or PDF +library. + +## Supported Markdown + +- ATX (`#` … `######`) and setext headings +- Paragraphs with `**bold**`, `*italic*`, `` `inline code` ``, + `~~strikethrough~~`, and CommonMark-style emphasis rules (no intraword `_`) +- Links: inline `[label](url)`, reference `[label][id]`, autolinks + ``, bare URLs, and `mailto:` autolinks — each drawn in blue with + a working link annotation (`LinkAnnotation` + `URIAction`) +- Ordered, unordered, and task lists (`- [x]`) with indentation levels, + tagged `L` → `LI` → `Lbl`/`LBody` +- Fenced code blocks (``` or `~~~`) with a language title row, gray + background, preserved indentation, and hard wrapping +- Blockquotes, horizontal rules, and pipe tables with per-column alignment, + tagged `Table` → `TR` → `TH`/`TD` +- An "HTML-lite" subset (`

`–`

`, ``, ``, ``, ``, + ``, `
`, block containers, HTML entities) normalized to Markdown + before parsing; remaining tags are stripped unless + `--include-unrendered-html` is given +- Image syntax is intentionally rendered as an `[Image omitted: …]` note; + the sample stays local-file only + +CJK text wraps per character and falls back to an installed font that covers +the script (`Font::is_text_representable`); Cyrillic and Greek get the same +treatment. + +## Building and running + +```sh +make # or open MarkdownToPDF.vcxproj in Visual Studio +./markdown_to_pdf # converts sample.md to MarkdownToPDF-out.pdf +./markdown_to_pdf input.md output.pdf # convert a specific file +./markdown_to_pdf --help # all options +./markdown_to_pdf --self-test # parser self-tests, no PDF written +./markdown_to_pdf --list-font-families # font families APDFL can use here +``` + +Useful options: `--page-size` (Letter, Legal, Ledger, A3, A4, A5, Tabloid, or +`WIDTHxHEIGHT` in points), `--orientation`, `--margin`, `--font-family`, +`--heading-font-family`, `--code-font-family`, `--cjk-font-family`, +`--fallback-fonts`, `--title`, `--lang`, `--overwrite`, `--verbose`. + +The `samples/` folder contains documents that exercise pagination, +multilingual text, HTML-lite input, and the image-omission behavior; see +`samples/README.md`. + +Set `APDFL_LICENSE_KEY` to provide a Datalogics APDFL activation key before +the library initializes. + +## Differences from the .NET sample + +This is a rewrite of the `apdfl-csharp-dotnet-samples` sample of the same +name using the Modern C++ interface. The Modern C++ API assigns marked-content +IDs and maintains the structure parent tree automatically +(`StructElement::add_marked_content_ref`), so the tagging code is +substantially simpler than the .NET version. Batch directory conversion +(`--recursive`) is not carried over; convert files one at a time. diff --git a/DocumentConversion/MarkdownToPDF/markdown_to_pdf.cpp b/DocumentConversion/MarkdownToPDF/markdown_to_pdf.cpp new file mode 100644 index 0000000..69ad3f3 --- /dev/null +++ b/DocumentConversion/MarkdownToPDF/markdown_to_pdf.cpp @@ -0,0 +1,2740 @@ +/* + * This sample converts a practical Markdown subset directly to a newly + * created, tagged PDF document using the Datalogics C++ APDFL API. It parses + * Markdown itself (headings, paragraphs, lists, task lists, tables, fenced + * code blocks, blockquotes, horizontal rules, links, emphasis, inline code, + * strikethrough, and an "HTML-lite" subset), lays the content out with real + * font metrics, and emits logical structure (H1-H6, P, L/LI, Table/TR/TH/TD, + * Code, BlockQuote, Link) so the output is a tagged, accessible PDF. + * + * Images are intentionally not embedded; they are replaced with an + * "[Image omitted: ...]" note so the sample stays local-file only. + * + * Copyright (c) Datalogics, Inc. All rights reserved. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace datalogics_interface; + +namespace { + +// ───────────────────────────────────────────────────────────────────────────── +// Command line +// ───────────────────────────────────────────────────────────────────────────── + +class CommandLineException : public std::runtime_error { +public: + explicit CommandLineException(const std::string& message) : std::runtime_error(message) {} +}; + +// ASCII-only classification helpers. The APDFL Library changes the process +// locale when it initializes, so the functions must not be used on +// UTF-8 text: multibyte continuation bytes would be misclassified. +bool ascii_space(unsigned char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; +} + +bool ascii_digit(unsigned char c) { return c >= '0' && c <= '9'; } + +bool ascii_alnum(unsigned char c) +{ + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); +} + +bool ascii_punct(unsigned char c) { return c >= '!' && c <= '~' && !ascii_alnum(c); } + +// Locale-independent floating point parse ('.' decimal separator). +double parse_double(const std::string& value) +{ + std::istringstream stream(value); + stream.imbue(std::locale::classic()); + double out = 0; + if (!(stream >> out) || !(stream >> std::ws).eof()) + throw std::invalid_argument("not a number: " + value); + return out; +} + +std::string to_lower(const std::string& s) +{ + std::string out = s; + for (char& c : out) { + if (c >= 'A' && c <= 'Z') + c = static_cast(c - 'A' + 'a'); + } + return out; +} + +enum class Orientation { Auto, Portrait, Landscape }; + +struct ConversionOptions { + std::string input_path = "sample.md"; + std::string output_path = "MarkdownToPDF-out.pdf"; + std::string title; // empty -> input file name + std::string language = "en-US"; + std::string page_size = "Letter"; + Orientation orientation = Orientation::Auto; + double margin = 72.0; + std::string font_family = "Times"; + std::string heading_font_family; // empty -> font_family + std::string code_font_family = "Courier"; + std::string cjk_font_family; + std::vector fallback_fonts; + bool overwrite = false; + bool verbose = false; + bool include_unrendered_html = false; +}; + +const std::map>& named_page_sizes() +{ + static const std::map> sizes = { + {"letter", {612, 792}}, {"legal", {612, 1008}}, {"ledger", {1224, 792}}, + {"a3", {842, 1191}}, {"a4", {595, 842}}, {"a5", {420, 595}}, + {"tabloid", {792, 1224}}, + }; + return sizes; +} + +std::pair resolve_page_size(const std::string& value) +{ + auto it = named_page_sizes().find(to_lower(value)); + if (it != named_page_sizes().end()) + return it->second; + + static const std::regex custom(R"(^(\d+(?:\.\d+)?)\s*[xX]\s*(\d+(?:\.\d+)?)$)"); + std::smatch m; + if (std::regex_match(value, m, custom)) { + double w = parse_double(m[1].str()); + double h = parse_double(m[2].str()); + if (w < 144 || w > 2880 || h < 144 || h > 2880) + throw CommandLineException("Custom page dimensions must be 144-2880 points: " + value); + return {w, h}; + } + throw CommandLineException( + "Unsupported page size: " + value + + ". Use Letter, Legal, Ledger, A3, A4, A5, Tabloid, or WIDTHxHEIGHT in points."); +} + +void print_usage() +{ + std::cout << + "Usage:\n" + " markdown_to_pdf [options]\n" + " markdown_to_pdf [options] input.md output.pdf\n" + "\n" + "With no positional arguments the sample converts sample.md to\n" + "MarkdownToPDF-out.pdf in the current directory.\n" + "\n" + "Document metadata:\n" + " --title PDF title. Defaults to the input file name.\n" + " --lang Document language tag. Defaults to en-US.\n" + "\n" + "Page setup:\n" + " --page-size Letter, Legal, Ledger, A3, A4, A5, Tabloid, or\n" + " WIDTHxHEIGHT in points. Defaults to Letter.\n" + " --orientation Auto, Portrait, or Landscape. Defaults to Auto.\n" + " --margin Margin on all sides in PDF points (18-144).\n" + " Defaults to 72.\n" + "\n" + "Fonts:\n" + " --font-family Body font family. Defaults to Times.\n" + " --heading-font-family Heading font family. Defaults to the body font.\n" + " --code-font-family Code font family. Defaults to Courier.\n" + " --cjk-font-family Preferred font for CJK text.\n" + " --fallback-fonts Comma-separated fallback font names.\n" + " --list-font-families List usable font families and exit.\n" + "\n" + "HTML handling:\n" + " --include-unrendered-html Keep unsupported raw HTML tags as literal text.\n" + "\n" + "Diagnostics and help:\n" + " --overwrite Replace the output file if it exists.\n" + " --verbose Print conversion settings after converting.\n" + " --self-test Run parser self-tests without creating a PDF.\n" + " --help Show this help.\n" + "\n" + "Set APDFL_LICENSE_KEY to provide a Datalogics APDFL activation key before\n" + "Library initialization.\n"; +} + +ConversionOptions parse_options(const std::vector& args) +{ + ConversionOptions options; + std::vector positional; + + auto require_value = [&](size_t& i, const std::string& option) -> const std::string& { + if (i + 1 >= args.size()) + throw CommandLineException(option + " requires a value."); + return args[++i]; + }; + + for (size_t i = 0; i < args.size(); ++i) { + const std::string& arg = args[i]; + if (arg.empty() || arg[0] != '-') { + positional.push_back(arg); + continue; + } + const std::string lower = to_lower(arg); + if (lower == "--title") { + options.title = require_value(i, arg); + } else if (lower == "--lang") { + options.language = require_value(i, arg); + if (options.language.empty()) + options.language = "en-US"; + } else if (lower == "--page-size") { + options.page_size = require_value(i, arg); + resolve_page_size(options.page_size); // validate early + } else if (lower == "--orientation") { + const std::string value = to_lower(require_value(i, arg)); + if (value == "auto") + options.orientation = Orientation::Auto; + else if (value == "portrait") + options.orientation = Orientation::Portrait; + else if (value == "landscape") + options.orientation = Orientation::Landscape; + else + throw CommandLineException("Orientation must be Auto, Portrait, or Landscape."); + } else if (lower == "--margin") { + const std::string value = require_value(i, arg); + try { + options.margin = parse_double(value); + } catch (const std::exception&) { + throw CommandLineException("Margin must be a number of points: " + value); + } + if (options.margin < 18 || options.margin > 144) + throw CommandLineException("Margin must be between 18 and 144 points."); + } else if (lower == "--font-family") { + options.font_family = require_value(i, arg); + } else if (lower == "--heading-font-family") { + options.heading_font_family = require_value(i, arg); + } else if (lower == "--code-font-family") { + options.code_font_family = require_value(i, arg); + } else if (lower == "--cjk-font-family") { + options.cjk_font_family = require_value(i, arg); + } else if (lower == "--fallback-fonts" || lower == "--fallback-font-family") { + std::stringstream stream(require_value(i, arg)); + std::string name; + while (std::getline(stream, name, ',')) { + const auto begin = name.find_first_not_of(" \t"); + const auto end = name.find_last_not_of(" \t"); + if (begin != std::string::npos) + options.fallback_fonts.push_back(name.substr(begin, end - begin + 1)); + } + } else if (lower == "--overwrite") { + options.overwrite = true; + } else if (lower == "--verbose") { + options.verbose = true; + } else if (lower == "--include-unrendered-html" || lower == "--include-raw-html") { + options.include_unrendered_html = true; + } else { + throw CommandLineException("Unknown option: " + arg); + } + } + + if (!positional.empty() && positional.size() != 2) + throw CommandLineException("Expected exactly two positional arguments: input.md output.pdf"); + if (positional.size() == 2) { + options.input_path = positional[0]; + options.output_path = positional[1]; + } else { + options.overwrite = true; // the bundled default output is safe to replace + } + if (options.heading_font_family.empty()) + options.heading_font_family = options.font_family; + if (!options.cjk_font_family.empty()) + options.fallback_fonts.insert(options.fallback_fonts.begin(), options.cjk_font_family); + return options; +} + +// ───────────────────────────────────────────────────────────────────────────── +// UTF-8 helpers +// ───────────────────────────────────────────────────────────────────────────── + +// Decodes the code point starting at `index` and advances `index` past it. +// Invalid sequences decode as one byte to keep the scanner moving. +unsigned int next_code_point(const std::string& text, size_t& index) +{ + const auto byte = [&](size_t at) { return static_cast(text[at]); }; + const unsigned char lead = byte(index); + unsigned int cp = lead; + size_t len = 1; + if (lead >= 0xF0 && index + 3 < text.size()) { + cp = ((lead & 0x07u) << 18) | ((byte(index + 1) & 0x3Fu) << 12) | + ((byte(index + 2) & 0x3Fu) << 6) | (byte(index + 3) & 0x3Fu); + len = 4; + } else if (lead >= 0xE0 && index + 2 < text.size()) { + cp = ((lead & 0x0Fu) << 12) | ((byte(index + 1) & 0x3Fu) << 6) | (byte(index + 2) & 0x3Fu); + len = 3; + } else if (lead >= 0xC0 && index + 1 < text.size()) { + cp = ((lead & 0x1Fu) << 6) | (byte(index + 1) & 0x3Fu); + len = 2; + } + index += len; + return cp; +} + +std::string encode_code_point(unsigned int cp) +{ + std::string out; + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else { + out.push_back(static_cast(0xF0 | (cp >> 18))); + out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + return out; +} + +bool is_cjk_code_point(unsigned int cp) +{ + return (cp >= 0x2E80 && cp <= 0x2EFF) || (cp >= 0x2F00 && cp <= 0x2FDF) || + (cp >= 0x3000 && cp <= 0x303F) || (cp >= 0x3040 && cp <= 0x30FF) || + (cp >= 0x3100 && cp <= 0x312F) || (cp >= 0x31C0 && cp <= 0x31EF) || + (cp >= 0x3400 && cp <= 0x4DBF) || (cp >= 0x4E00 && cp <= 0x9FFF) || + (cp >= 0xAC00 && cp <= 0xD7AF) || (cp >= 0xF900 && cp <= 0xFAFF) || + (cp >= 0xFF00 && cp <= 0xFFEF); +} + +bool is_cyrillic_or_greek_code_point(unsigned int cp) +{ + return (cp >= 0x0370 && cp <= 0x03FF) || (cp >= 0x0400 && cp <= 0x052F); +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTML-lite normalization: convert a small, common HTML subset to Markdown so +// documents that mix the two still render. Runs before block parsing and +// skips fenced code regions. +// ───────────────────────────────────────────────────────────────────────────── + +std::string decode_html_entities(const std::string& text) +{ + static const std::map named = { + {"amp", "&"}, {"lt", "<"}, {"gt", ">"}, {"quot", "\""}, + {"apos", "'"}, {"nbsp", " "}, {"mdash", u8"—"}, {"ndash", u8"–"}, + {"hellip", u8"…"}, {"copy", u8"©"}, {"bull", u8"•"}, {"middot", u8"·"}, + }; + std::string out; + out.reserve(text.size()); + for (size_t i = 0; i < text.size();) { + if (text[i] != '&') { + out.push_back(text[i++]); + continue; + } + const size_t semi = text.find(';', i + 1); + if (semi == std::string::npos || semi - i > 10) { + out.push_back(text[i++]); + continue; + } + const std::string body = text.substr(i + 1, semi - i - 1); + if (!body.empty() && body[0] == '#') { + unsigned int cp = 0; + try { + cp = (body.size() > 1 && (body[1] == 'x' || body[1] == 'X')) + ? static_cast(std::stoul(body.substr(2), nullptr, 16)) + : static_cast(std::stoul(body.substr(1))); + } catch (const std::exception&) { + cp = 0; + } + if (cp != 0) { + out += encode_code_point(cp); + i = semi + 1; + continue; + } + } + auto it = named.find(to_lower(body)); + if (it != named.end()) { + out += it->second; + i = semi + 1; + continue; + } + out.push_back(text[i++]); + } + return out; +} + +std::string strip_tags(const std::string& text) +{ + static const std::regex tag(R"(]*?)?/?>)"); + return std::regex_replace(text, tag, ""); +} + +std::string normalize_html_line(const std::string& input, bool include_unrendered_html) +{ + std::string line = input; + + static const std::regex comment(R"()"); + line = std::regex_replace(line, comment, ""); + + //

..

-> ATX headings on their own line. + static const std::regex heading(R"(<[hH]([1-6])[^>]*>(.*?))"); + std::smatch m; + while (std::regex_search(line, m, heading)) { + const int level = m[1].str()[0] - '0'; + const std::string text = decode_html_entities(strip_tags(m[2].str())); + line = std::string(m.prefix()) + "\n" + std::string(static_cast(level), '#') + + " " + text + "\n" + std::string(m.suffix()); + } + + //
label -> [label](url) + static const std::regex anchor( + R"re(<[aA]\s+[^>]*[hH][rR][eE][fF]\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>(.*?))re"); + while (std::regex_search(line, m, anchor)) { + std::string url = m[1].matched ? m[1].str() : (m[2].matched ? m[2].str() : m[3].str()); + url = decode_html_entities(url); + const std::string label = decode_html_entities(strip_tags(m[4].str())); + const std::string replacement = + url.empty() ? label : "[" + label + "](" + url + ")"; + line = std::string(m.prefix()) + replacement + std::string(m.suffix()); + } + + // -> [Image omitted: alt-or-src] + static const std::regex img(R"(<[iI][mM][gG]\b[^>]*/?>)"); + static const std::regex alt_attr(R"re([aA][lL][tT]\s*=\s*(?:"([^"]*)"|'([^']*)'))re"); + static const std::regex src_attr(R"re([sS][rR][cC]\s*=\s*(?:"([^"]*)"|'([^']*)'))re"); + while (std::regex_search(line, m, img)) { + const std::string tag = m[0].str(); + std::smatch attr; + std::string label; + if (std::regex_search(tag, attr, alt_attr)) + label = attr[1].matched ? attr[1].str() : attr[2].str(); + if (label.empty() && std::regex_search(tag, attr, src_attr)) + label = attr[1].matched ? attr[1].str() : attr[2].str(); + const std::string replacement = + label.empty() ? "[Image omitted]" : "[Image omitted: " + decode_html_entities(label) + "]"; + line = std::string(m.prefix()) + replacement + std::string(m.suffix()); + } + + // Paired style tags -> Markdown, repeated until stable to handle nesting. + struct PairedTag { const char* pattern; const char* prefix; const char* suffix; }; + static const PairedTag paired[] = { + {R"(<(?:strong|b|STRONG|B)\b[^>]*>(.*?))", "**", "**"}, + {R"(<(?:em|i|EM|I)\b[^>]*>(.*?))", "*", "*"}, + {R"(<(?:code|kbd|samp|CODE|KBD|SAMP)\b[^>]*>(.*?))", "`", "`"}, + {R"(<(?:del|s|strike|DEL|S|STRIKE)\b[^>]*>(.*?))", "~~", "~~"}, + }; + bool changed = true; + while (changed) { + changed = false; + for (const auto& entry : paired) { + const std::regex pattern(entry.pattern); + if (std::regex_search(line, m, pattern)) { + const std::string inner = decode_html_entities(strip_tags(m[1].str())); + line = std::string(m.prefix()) + entry.prefix + inner + entry.suffix + + std::string(m.suffix()); + changed = true; + } + } + } + + static const std::regex br(R"(<[bB][rR]\s*/?>)"); + line = std::regex_replace(line, br, "\n\n"); + + static const std::regex block_tag( + R"(]*>)", + std::regex::icase); + line = std::regex_replace(line, block_tag, "\n"); + + static const std::regex inline_tag(R"(]*>)", + std::regex::icase); + line = std::regex_replace(line, inline_tag, ""); + + if (!include_unrendered_html) + line = strip_tags(line); + + return decode_html_entities(line); +} + +std::string normalize_html(const std::string& markdown, bool include_unrendered_html) +{ + std::string text = markdown; + // Normalize newlines. + text = std::regex_replace(text, std::regex("\r\n"), "\n"); + std::replace(text.begin(), text.end(), '\r', '\n'); + + std::stringstream stream(text); + std::string line; + std::string out; + bool in_fence = false; + std::string fence_marker; + while (std::getline(stream, line)) { + std::string trimmed = line; + const auto first = trimmed.find_first_not_of(" \t"); + trimmed = first == std::string::npos ? "" : trimmed.substr(first); + const bool fence_line = + trimmed.rfind("```", 0) == 0 || trimmed.rfind("~~~", 0) == 0; + if (fence_line) { + const std::string marker = trimmed.substr(0, 3); + if (!in_fence) { + in_fence = true; + fence_marker = marker; + } else if (marker == fence_marker) { + in_fence = false; + } + out += line + "\n"; + continue; + } + out += (in_fence ? line : normalize_html_line(line, include_unrendered_html)) + "\n"; + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Markdown model +// ───────────────────────────────────────────────────────────────────────────── + +struct InlineRun { + std::string text; + bool bold = false; + bool italic = false; + bool code = false; + bool strike = false; + bool link = false; + std::string url; + + bool same_style(const InlineRun& other) const + { + return bold == other.bold && italic == other.italic && code == other.code && + strike == other.strike && link == other.link && url == other.url; + } +}; + +enum class ColumnAlignment { Left, Center, Right }; + +struct ListItem { + std::vector runs; + int level = 0; + int number = 0; + int task_state = -1; // -1 none, 0 unchecked, 1 checked +}; + +enum class BlockType { Heading, Paragraph, CodeBlock, List, BlockQuote, Table, HorizontalRule }; + +struct Block { + BlockType type = BlockType::Paragraph; + int heading_level = 1; + std::vector runs; // heading / paragraph / quote + std::string language; // code block + std::vector code_lines; // code block + bool ordered = false; // list + std::vector items; // list + std::vector> header_cells; // table + std::vector>> rows; // table + std::vector alignments; // table +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Inline parser +// ───────────────────────────────────────────────────────────────────────────── + +class InlineParser { +public: + explicit InlineParser(const std::map& references) + : references_(references) + { + } + + std::vector parse(const std::string& text) const + { + InlineRun base; + std::vector runs; + parse_into(text, base, runs); + return merge(runs); + } + + static std::string normalize_label(const std::string& label) + { + std::string out; + bool pending_space = false; + for (char c : label) { + if (ascii_space(static_cast(c))) { + pending_space = !out.empty(); + continue; + } + if (pending_space) { + out.push_back(' '); + pending_space = false; + } + if (c >= 'A' && c <= 'Z') + c = static_cast(c - 'A' + 'a'); + out.push_back(c); + } + return out; + } + +private: + const std::map& references_; + + static std::vector merge(const std::vector& runs) + { + std::vector out; + for (const auto& run : runs) { + if (run.text.empty()) + continue; + if (!out.empty() && out.back().same_style(run)) + out.back().text += run.text; + else + out.push_back(run); + } + return out; + } + + static void append_text(std::vector& runs, const InlineRun& style, + const std::string& text) + { + if (text.empty()) + return; + InlineRun run = style; + run.text = text; + runs.push_back(run); + } + + enum class CharClass { Whitespace, Punctuation, Other }; + + static CharClass classify(const std::string& text, size_t index) + { + if (index >= text.size()) + return CharClass::Whitespace; + const unsigned char c = static_cast(text[index]); + if (ascii_space(c)) + return CharClass::Whitespace; + if (ascii_punct(c)) + return CharClass::Punctuation; + return CharClass::Other; // letters, digits, and all non-ASCII bytes + } + + static CharClass classify_before(const std::string& text, size_t index) + { + if (index == 0) + return CharClass::Whitespace; + return classify(text, index - 1); + } + + // CommonMark-flavored flanking checks for * and _ delimiter runs. + static bool can_open(const std::string& text, size_t start, size_t len, char delim) + { + const CharClass after = classify(text, start + len); + const CharClass before = classify_before(text, start); + const bool left_flanking = + after != CharClass::Whitespace && + (after != CharClass::Punctuation || + before == CharClass::Whitespace || before == CharClass::Punctuation); + if (!left_flanking) + return false; + if (delim == '_' && before == CharClass::Other) + return false; // no intraword emphasis with underscore + return true; + } + + static bool can_close(const std::string& text, size_t start, size_t len, char delim) + { + const CharClass before = classify_before(text, start); + const CharClass after = classify(text, start + len); + const bool right_flanking = + before != CharClass::Whitespace && + (before != CharClass::Punctuation || + after == CharClass::Whitespace || after == CharClass::Punctuation); + if (!right_flanking) + return false; + if (delim == '_' && after == CharClass::Other) + return false; + return true; + } + + static size_t delimiter_run_length(const std::string& text, size_t index) + { + const char delim = text[index]; + size_t len = 0; + while (index + len < text.size() && text[index + len] == delim) + ++len; + return len; + } + + void parse_into(const std::string& text, const InlineRun& style, + std::vector& runs) const + { + std::string plain; + auto flush = [&]() { + append_text(runs, style, plain); + plain.clear(); + }; + + for (size_t i = 0; i < text.size();) { + const char c = text[i]; + + if (c == '\\' && i + 1 < text.size()) { + plain.push_back(text[i + 1]); + i += 2; + continue; + } + + if (c == '`') { + const size_t close = text.find('`', i + 1); + if (close != std::string::npos) { + flush(); + InlineRun code = style; + code.code = true; + append_text(runs, code, text.substr(i + 1, close - i - 1)); + i = close + 1; + continue; + } + } + + if (c == '~' && i + 1 < text.size() && text[i + 1] == '~') { + const size_t close = find_strike_close(text, i + 2); + if (close != std::string::npos && close > i + 2) { + flush(); + InlineRun strike = style; + strike.strike = true; + parse_into(text.substr(i + 2, close - i - 2), strike, runs); + i = close + 2; + continue; + } + } + + if (c == '!' && i + 1 < text.size() && text[i + 1] == '[') { + std::string alt, url; + size_t consumed = 0; + if (parse_bracket_pair(text, i + 1, alt, url, consumed)) { + flush(); + InlineRun note = style; + note.italic = true; + std::string label = !alt.empty() ? alt : url; + append_text(runs, note, + label.empty() ? "[Image omitted]" : "[Image omitted: " + label + "]"); + i += 1 + consumed; + continue; + } + } + + if (c == '[') { + if (try_parse_link(text, i, style, runs, plain)) + continue; + } + + if (c == '<') { + if (try_parse_autolink(text, i, style, runs, plain)) + continue; + } + + if ((c == 'h') && (text.compare(i, 7, "http://") == 0 || + text.compare(i, 8, "https://") == 0)) { + size_t end = i; + while (end < text.size() && + !ascii_space(static_cast(text[end])) && text[end] != '<') + ++end; + std::string url = text.substr(i, end - i); + while (!url.empty() && std::string(".,;:)]").find(url.back()) != std::string::npos) + url.pop_back(); + if (url.size() > 8) { + flush(); + InlineRun link = style; + link.link = true; + link.url = url; + append_text(runs, link, url); + i += url.size(); + continue; + } + } + + if (c == '*' || c == '_') { + const size_t run_len = delimiter_run_length(text, i); + const size_t use_len = std::min(run_len, 3); + if (can_open(text, i, run_len, c)) { + const size_t close = find_emphasis_close(text, i + run_len, c, use_len); + if (close != std::string::npos) { + flush(); + InlineRun inner = style; + if (use_len >= 2) + inner.bold = true; + if (use_len == 1 || use_len == 3) + inner.italic = true; + parse_into(text.substr(i + use_len, close - i - use_len), inner, runs); + i = close + use_len; + continue; + } + } + plain += text.substr(i, run_len); + i += run_len; + continue; + } + + plain.push_back(c); + ++i; + } + flush(); + } + + static size_t find_strike_close(const std::string& text, size_t from) + { + for (size_t i = from; i + 1 < text.size(); ++i) { + if (text[i] == '~' && text[i + 1] == '~' && + !ascii_space(static_cast(text[i - 1]))) + return i; + } + return std::string::npos; + } + + static size_t find_emphasis_close(const std::string& text, size_t from, char delim, + size_t length) + { + for (size_t i = from; i < text.size(); ++i) { + if (text[i] == '\\') { + ++i; + continue; + } + if (text[i] != delim) + continue; + const size_t run_len = delimiter_run_length(text, i); + if (run_len >= length && can_close(text, i, run_len, delim) && i > from) + return i; + i += run_len - 1; + } + return std::string::npos; + } + + // Parses [text](url) starting at `start` (which must point at '['). + static bool parse_bracket_pair(const std::string& text, size_t start, std::string& label, + std::string& url, size_t& consumed) + { + if (start >= text.size() || text[start] != '[') + return false; + int depth = 1; + size_t i = start + 1; + while (i < text.size() && depth > 0) { + if (text[i] == '\\') + ++i; + else if (text[i] == '[') + ++depth; + else if (text[i] == ']') + --depth; + ++i; + } + if (depth != 0 || i >= text.size() || text[i] != '(') + return false; + label = text.substr(start + 1, i - start - 2); + const size_t close = text.find(')', i + 1); + if (close == std::string::npos) + return false; + url = text.substr(i + 1, close - i - 1); + consumed = close - start + 1; + return true; + } + + bool try_parse_link(const std::string& text, size_t& i, const InlineRun& style, + std::vector& runs, std::string& plain) const + { + auto emit_link = [&](const std::string& label, const std::string& url, size_t consumed) { + append_text(runs, style, plain); + plain.clear(); + InlineRun link = style; + link.link = true; + link.url = url; + append_text(runs, link, label.empty() ? url : label); + i += consumed; + }; + + std::string label, url; + size_t consumed = 0; + if (parse_bracket_pair(text, i, label, url, consumed)) { + emit_link(label, url, consumed); + return true; + } + + // Reference forms: [label][id], [label][], [id] + const size_t label_close = text.find(']', i + 1); + if (label_close == std::string::npos) + return false; + label = text.substr(i + 1, label_close - i - 1); + std::string ref_id = label; + size_t end = label_close + 1; + if (end < text.size() && text[end] == '[') { + const size_t ref_close = text.find(']', end + 1); + if (ref_close == std::string::npos) + return false; + const std::string explicit_id = text.substr(end + 1, ref_close - end - 1); + if (!explicit_id.empty()) + ref_id = explicit_id; + end = ref_close + 1; + } + auto it = references_.find(normalize_label(ref_id)); + if (it == references_.end()) + return false; + emit_link(label, it->second, end - i); + return true; + } + + bool try_parse_autolink(const std::string& text, size_t& i, const InlineRun& style, + std::vector& runs, std::string& plain) const + { + const size_t close = text.find('>', i + 1); + if (close == std::string::npos) + return false; + const std::string body = text.substr(i + 1, close - i - 1); + static const std::regex email(R"(^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$)"); + std::string url; + if (body.rfind("http://", 0) == 0 || body.rfind("https://", 0) == 0) + url = body; + else if (std::regex_match(body, email)) + url = "mailto:" + body; + else + return false; + append_text(runs, style, plain); + plain.clear(); + InlineRun link = style; + link.link = true; + link.url = url; + append_text(runs, link, body); + i = close + 1; + return true; + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Block parser +// ───────────────────────────────────────────────────────────────────────────── + +class MarkdownParser { +public: + std::vector parse(const std::string& markdown, bool include_unrendered_html) const + { + const std::string normalized = normalize_html(markdown, include_unrendered_html); + std::vector lines; + { + std::stringstream stream(normalized); + std::string line; + while (std::getline(stream, line)) + lines.push_back(expand_tabs(line)); + } + + std::map references; + extract_references(lines, references); + InlineParser inline_parser(references); + + std::vector blocks; + size_t i = 0; + while (i < lines.size()) { + const std::string& line = lines[i]; + if (is_blank(line)) { + ++i; + continue; + } + if (is_fence(line)) { + blocks.push_back(parse_code_block(lines, i)); + continue; + } + if (Block heading; try_parse_atx_heading(line, inline_parser, heading)) { + blocks.push_back(std::move(heading)); + ++i; + continue; + } + if (is_horizontal_rule(line)) { + Block hr; + hr.type = BlockType::HorizontalRule; + blocks.push_back(std::move(hr)); + ++i; + continue; + } + if (Block heading; try_parse_setext_heading(lines, i, inline_parser, heading)) { + blocks.push_back(std::move(heading)); + continue; + } + if (trimmed_start(line).rfind('>', 0) == 0) { + blocks.push_back(parse_blockquote(lines, i, inline_parser)); + continue; + } + if (Block table; try_parse_table(lines, i, inline_parser, table)) { + blocks.push_back(std::move(table)); + continue; + } + if (int level, number; is_list_item(line, level, number) >= 0) { + blocks.push_back(parse_list(lines, i, inline_parser)); + continue; + } + blocks.push_back(parse_paragraph(lines, i, inline_parser)); + } + return blocks; + } + +private: + static std::string expand_tabs(const std::string& line) + { + std::string out; + for (char c : line) { + if (c == '\t') + out += " "; + else + out.push_back(c); + } + return out; + } + + static bool is_blank(const std::string& line) + { + return line.find_first_not_of(" \t") == std::string::npos; + } + + static std::string trimmed_start(const std::string& line) + { + const auto first = line.find_first_not_of(" \t"); + return first == std::string::npos ? "" : line.substr(first); + } + + static std::string trim(const std::string& text) + { + const auto first = text.find_first_not_of(" \t"); + if (first == std::string::npos) + return ""; + const auto last = text.find_last_not_of(" \t"); + return text.substr(first, last - first + 1); + } + + static bool is_fence(const std::string& line) + { + const std::string t = trimmed_start(line); + return t.rfind("```", 0) == 0 || t.rfind("~~~", 0) == 0; + } + + static bool is_horizontal_rule(const std::string& line) + { + static const std::regex hr(R"(^\s{0,3}([-*_])(?:\s*\1){2,}\s*$)"); + return std::regex_match(line, hr); + } + + // Returns marker kind: -1 not a list item, 0 unordered, 1 ordered. + static int is_list_item(const std::string& line, int& level, int& number) + { + static const std::regex item(R"(^(\s*)([-+*]|\d+[.)])\s+(.*)$)"); + std::smatch m; + if (!std::regex_match(line, m, item)) + return -1; + level = std::min(6, static_cast(m[1].str().size()) / 2); + const std::string marker = m[2].str(); + if (ascii_digit(static_cast(marker[0]))) { + number = std::atoi(marker.c_str()); + return 1; + } + number = 0; + return 0; + } + + static bool starts_new_block(const std::string& line) + { + if (is_blank(line) || is_fence(line) || is_horizontal_rule(line)) + return true; + static const std::regex atx(R"(^#{1,6}(?:\s+|$).*$)"); + if (std::regex_match(line, atx)) + return true; + if (trimmed_start(line).rfind('>', 0) == 0) + return true; + int level, number; + return is_list_item(line, level, number) >= 0; + } + + static void extract_references(std::vector& lines, + std::map& references) + { + static const std::regex definition( + R"(^\s{0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*$)"); + bool in_fence = false; + for (auto& line : lines) { + if (is_fence(line)) { + in_fence = !in_fence; + continue; + } + if (in_fence) + continue; + std::smatch m; + if (std::regex_match(line, m, definition)) { + const std::string key = InlineParser::normalize_label(m[1].str()); + references.emplace(key, m[2].str()); // first definition wins + line.clear(); + } + } + } + + static bool try_parse_atx_heading(const std::string& line, const InlineParser& inline_parser, + Block& out) + { + static const std::regex atx(R"(^(#{1,6})(?:\s+|$)(.*)$)"); + std::smatch m; + if (!std::regex_match(line, m, atx)) + return false; + std::string text = m[2].str(); + // Strip a trailing closing-hash run. + static const std::regex trailing(R"(\s+#+\s*$)"); + text = std::regex_replace(text, trailing, ""); + out.type = BlockType::Heading; + out.heading_level = static_cast(m[1].str().size()); + out.runs = inline_parser.parse(trim(text)); + return true; + } + + static bool try_parse_setext_heading(const std::vector& lines, size_t& i, + const InlineParser& inline_parser, Block& out) + { + if (i + 1 >= lines.size()) + return false; + static const std::regex underline(R"(^\s{0,3}(=+|-+)\s*$)"); + std::smatch m; + if (!std::regex_match(lines[i + 1], m, underline)) + return false; + const std::string& text = lines[i]; + if (starts_new_block(text) || text.find('|') != std::string::npos) + return false; + out.type = BlockType::Heading; + out.heading_level = m[1].str()[0] == '=' ? 1 : 2; + out.runs = inline_parser.parse(trim(text)); + i += 2; + return true; + } + + static Block parse_code_block(const std::vector& lines, size_t& i) + { + Block block; + block.type = BlockType::CodeBlock; + const std::string opener = trimmed_start(lines[i]); + const std::string marker = opener.substr(0, 3); + block.language = trim(opener.substr(3)); + ++i; + while (i < lines.size()) { + const std::string t = trimmed_start(lines[i]); + if (t.rfind(marker, 0) == 0) { + ++i; + break; + } + block.code_lines.push_back(lines[i]); + ++i; + } + return block; + } + + static Block parse_blockquote(const std::vector& lines, size_t& i, + const InlineParser& inline_parser) + { + std::string text; + while (i < lines.size()) { + std::string t = trimmed_start(lines[i]); + if (t.rfind('>', 0) != 0) + break; + t.erase(0, 1); + if (!t.empty() && t[0] == ' ') + t.erase(0, 1); + if (!text.empty()) + text += " "; + text += t; + ++i; + } + Block block; + block.type = BlockType::BlockQuote; + block.runs = inline_parser.parse(trim(text)); + return block; + } + + static std::vector split_table_cells(const std::string& line) + { + std::string body = trim(line); + if (!body.empty() && body.front() == '|') + body.erase(0, 1); + if (!body.empty() && body.back() == '|' && + (body.size() < 2 || body[body.size() - 2] != '\\')) + body.pop_back(); + std::vector cells; + std::string current; + for (size_t i = 0; i < body.size(); ++i) { + if (body[i] == '\\' && i + 1 < body.size() && body[i + 1] == '|') { + current.push_back('|'); + ++i; + } else if (body[i] == '|') { + cells.push_back(trim(current)); + current.clear(); + } else { + current.push_back(body[i]); + } + } + cells.push_back(trim(current)); + return cells; + } + + static bool has_unescaped_pipe(const std::string& line) + { + for (size_t i = 0; i < line.size(); ++i) { + if (line[i] == '|' && (i == 0 || line[i - 1] != '\\')) + return true; + } + return false; + } + + static bool try_parse_table(const std::vector& lines, size_t& i, + const InlineParser& inline_parser, Block& out) + { + if (i + 1 >= lines.size() || !has_unescaped_pipe(lines[i])) + return false; + const std::vector separator_cells = split_table_cells(lines[i + 1]); + if (separator_cells.empty()) + return false; + std::vector alignments; + static const std::regex separator(R"(^(:?)-{1,}(:?)$)"); + for (const auto& cell : separator_cells) { + std::smatch m; + if (cell.size() < 3 || !std::regex_match(cell, m, separator)) + return false; + if (m[1].length() > 0 && m[2].length() > 0) + alignments.push_back(ColumnAlignment::Center); + else if (m[2].length() > 0) + alignments.push_back(ColumnAlignment::Right); + else + alignments.push_back(ColumnAlignment::Left); + } + + std::vector header_cells = split_table_cells(lines[i]); + const size_t columns = std::max(header_cells.size(), alignments.size()); + header_cells.resize(columns); + alignments.resize(columns, ColumnAlignment::Left); + + out.type = BlockType::Table; + out.alignments = alignments; + for (const auto& cell : header_cells) + out.header_cells.push_back(inline_parser.parse(cell)); + + i += 2; + while (i < lines.size()) { + const std::string& line = lines[i]; + if (is_blank(line) || is_fence(line) || is_horizontal_rule(line) || + trimmed_start(line).rfind('>', 0) == 0 || !has_unescaped_pipe(line)) + break; + static const std::regex atx(R"(^#{1,6}(?:\s+|$).*$)"); + if (std::regex_match(line, atx)) + break; + std::vector cells = split_table_cells(line); + cells.resize(columns); + std::vector> row; + for (const auto& cell : cells) + row.push_back(inline_parser.parse(cell)); + out.rows.push_back(std::move(row)); + ++i; + } + return true; + } + + static Block parse_list(const std::vector& lines, size_t& i, + const InlineParser& inline_parser) + { + Block block; + block.type = BlockType::List; + int first_level, first_number; + block.ordered = is_list_item(lines[i], first_level, first_number) == 1; + + while (i < lines.size()) { + int level, number; + const int kind = is_list_item(lines[i], level, number); + if (kind < 0 || (kind == 1) != block.ordered) + break; + + static const std::regex item(R"(^(\s*)([-+*]|\d+[.)])\s+(.*)$)"); + std::smatch m; + std::regex_match(lines[i], m, item); + std::string text = m[3].str(); + ++i; + + ListItem entry; + entry.level = level; + entry.number = number; + const std::string trimmed = trim(text); + if (trimmed.rfind("[ ]", 0) == 0) { + entry.task_state = 0; + text = trim(trimmed.substr(3)); + } else if (trimmed.rfind("[x]", 0) == 0 || trimmed.rfind("[X]", 0) == 0) { + entry.task_state = 1; + text = trim(trimmed.substr(3)); + } + + // Continuation lines: more-indented plain text is appended. + while (i < lines.size()) { + const std::string& next = lines[i]; + int next_level, next_number; + if (is_blank(next) || is_list_item(next, next_level, next_number) >= 0 || + starts_new_block(next)) + break; + const size_t indent = next.find_first_not_of(' '); + if (indent == std::string::npos || + static_cast(indent) <= level * 2) + break; + text += " " + trim(next); + ++i; + } + + entry.runs = inline_parser.parse(trim(text)); + block.items.push_back(std::move(entry)); + + if (i < lines.size() && is_blank(lines[i])) + break; + } + return block; + } + + static Block parse_paragraph(const std::vector& lines, size_t& i, + const InlineParser& inline_parser) + { + std::string text = trim(lines[i]); + ++i; + while (i < lines.size() && !starts_new_block(lines[i]) && + !(i + 1 < lines.size() && + std::regex_match(lines[i + 1], std::regex(R"(^\s{0,3}(=+|-+)\s*$)")))) { + if (has_unescaped_pipe(lines[i]) && i + 1 < lines.size()) + break; // could be a table header + text += " " + trim(lines[i]); + ++i; + } + Block block; + block.type = BlockType::Paragraph; + block.runs = inline_parser.parse(text); + return block; + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Fonts: family catalog with base-14 defaults plus fonts discovered from the +// machine, and per-script fallback resolution driven by +// Font::is_text_representable. +// ───────────────────────────────────────────────────────────────────────────── + +std::string normalize_font_key(const std::string& name) +{ + std::string out; + for (char c : name) { + if (ascii_alnum(static_cast(c))) { + if (c >= 'A' && c <= 'Z') + c = static_cast(c - 'A' + 'a'); + out.push_back(c); + } + } + return out; +} + +struct FontFamily { + std::string display_name; + std::string regular; + std::string bold; + std::string italic; + std::string bold_italic; + + const std::string& face(bool want_bold, bool want_italic) const + { + if (want_bold && want_italic && !bold_italic.empty()) + return bold_italic; + if (want_bold && !bold.empty()) + return bold; + if (want_italic && !italic.empty()) + return italic; + if (want_bold && !bold_italic.empty()) + return bold_italic; + return regular; + } +}; + +class FontCatalog { +public: + FontCatalog() + { + add_core("Times", {"Times", "Times-Roman", "Times-Bold", "Times-Italic", "Times-BoldItalic"}, + {"timesroman", "serif"}); + add_core("Helvetica", + {"Helvetica", "Helvetica", "Helvetica-Bold", "Helvetica-Oblique", + "Helvetica-BoldOblique"}, + {"sans", "sansserif"}); + add_core("Courier", + {"Courier", "Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"}, + {"mono", "monospace"}); + discover_installed_fonts(); + } + + const FontFamily* find(const std::string& name) const + { + auto it = families_.find(normalize_font_key(name)); + if (it != families_.end()) + return &it->second; + auto alias = aliases_.find(normalize_font_key(name)); + if (alias != aliases_.end()) + return &families_.at(alias->second); + return nullptr; + } + + const FontFamily& require(const std::string& name) const + { + const FontFamily* family = find(name); + if (!family) + throw CommandLineException( + "Unknown font family: " + name + + ". Use --list-font-families to see what is available."); + return *family; + } + + void print_families() const + { + std::cout << "Core PDF fonts:" << std::endl; + std::cout << " Times" << std::endl << " Helvetica" << std::endl + << " Courier" << std::endl; + std::vector others; + for (const auto& [key, family] : families_) { + if (key != "times" && key != "helvetica" && key != "courier") + others.push_back(family.display_name); + } + std::sort(others.begin(), others.end()); + if (!others.empty()) { + std::cout << "Other fonts available to APDFL on this machine (" << others.size() + << "):" << std::endl; + for (const auto& name : others) + std::cout << " " << name << std::endl; + } + std::cout << "Pass any of these to --font-family, --heading-font-family, or\n" + "--code-font-family. Times, Helvetica, and Courier remain the most\n" + "portable choices." << std::endl; + } + +private: + std::map families_; + std::map aliases_; + + void add_core(const std::string& display, + const std::vector& display_and_faces, + const std::vector& alias_names) + { + FontFamily family; + family.display_name = display; + family.regular = display_and_faces[1]; + family.bold = display_and_faces[2]; + family.italic = display_and_faces[3]; + family.bold_italic = display_and_faces[4]; + const std::string key = normalize_font_key(display); + families_[key] = family; + for (const auto& alias : alias_names) + aliases_[alias] = key; + } + + // Groups every font APDFL can see into families keyed by a name with the + // style suffix stripped, so "--font-family Arial" resolves Arial-BoldMT + // and friends. + void discover_installed_fonts() + { + std::vector installed; + try { + installed = Font::get_font_list(); + } catch (const std::exception&) { + return; // font enumeration is best-effort + } + for (const auto& font : installed) { + std::string name; + try { + name = font.get_name(); + } catch (const std::exception&) { + continue; + } + if (name.empty()) + continue; + + std::string base = name; + bool bold = false, italic = false; + strip_style_suffix(base, bold, italic); + + const std::string key = normalize_font_key(base); + if (key.empty()) + continue; + auto [it, inserted] = families_.emplace(key, FontFamily{}); + FontFamily& family = it->second; + if (inserted) + family.display_name = display_name_for(base); + std::string& slot = bold && italic ? family.bold_italic + : bold ? family.bold + : italic ? family.italic + : family.regular; + if (slot.empty()) + slot = name; + } + // Families discovered without a regular face fall back to any face. + for (auto& [key, family] : families_) { + if (family.regular.empty()) { + family.regular = !family.bold.empty() ? family.bold + : !family.italic.empty() ? family.italic + : family.bold_italic; + } + } + } + + static void strip_style_suffix(std::string& base, bool& bold, bool& italic) + { + static const std::pair> suffixes[] = { + {"-BoldOblique", {true, true}}, {"-BoldItalic", {true, true}}, + {"BoldOblique", {true, true}}, {"BoldItalic", {true, true}}, + {"-Oblique", {false, true}}, {"-Italic", {false, true}}, + {"-Bold", {true, false}}, {"-Regular", {false, false}}, + {"Oblique", {false, true}}, {"Italic", {false, true}}, + {"Bold", {true, false}}, {"Regular", {false, false}}, + }; + // Trim foundry adornments first. + for (const char* adornment : {"PSMT", "PS", "MT"}) { + const size_t len = std::strlen(adornment); + if (base.size() > len && base.compare(base.size() - len, len, adornment) == 0) + base.erase(base.size() - len); + } + for (const auto& [suffix, style] : suffixes) { + const size_t len = std::strlen(suffix); + if (base.size() > len && base.compare(base.size() - len, len, suffix) == 0) { + base.erase(base.size() - len); + bold = style.first; + italic = style.second; + break; + } + } + while (!base.empty() && (base.back() == '-' || base.back() == ',')) + base.pop_back(); + std::replace(base.begin(), base.end(), '-', ' '); + } + + static std::string display_name_for(const std::string& base) + { + std::string out; + for (size_t i = 0; i < base.size(); ++i) { + const char c = base[i]; + if (i > 0 && c >= 'A' && c <= 'Z' && base[i - 1] >= 'a' && + base[i - 1] <= 'z') + out.push_back(' '); + out.push_back(c); + } + return out; + } +}; + +enum class FontRole { Body, Heading, Code }; + +class FontSet { +public: + FontSet(const FontCatalog& catalog, const ConversionOptions& options) : catalog_(catalog) + { + body_ = &catalog.require(options.font_family); + heading_ = &catalog.require(options.heading_font_family); + code_ = &catalog.require(options.code_font_family); + + fallback_names_ = options.fallback_fonts; + for (const char* name : + {"Arial", "Times New Roman", "Calibri", "Segoe UI", "DejaVu Sans", "Noto Sans", + "Microsoft YaHei", "SimSun", "Microsoft JhengHei", "Malgun Gothic", "Yu Gothic", + "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR", "Arial Unicode MS", + "Helvetica Neue", "PingFang SC", "Hiragino Sans", "Apple SD Gothic Neo"}) { + fallback_names_.push_back(name); + } + } + + // Resolves the font for one styled piece of text. `sample` is a UTF-8 + // string used to check glyph coverage for fallback scripts. + Font& resolve(const InlineRun& run, FontRole role, const std::string& sample) + { + if (!sample.empty()) { + size_t index = 0; + const unsigned int cp = next_code_point(sample, index); + if (is_cjk_code_point(cp) || is_cyrillic_or_greek_code_point(cp)) { + Font* fallback = resolve_fallback(sample, run); + if (fallback) + return *fallback; + } + } + if (run.code || role == FontRole::Code) + return get_face(code_->face(false, false)); + const FontFamily* family = role == FontRole::Heading ? heading_ : body_; + return get_face(family->face(run.bold, run.italic)); + } + +private: + const FontCatalog& catalog_; + const FontFamily* body_; + const FontFamily* heading_; + const FontFamily* code_; + std::vector fallback_names_; + std::map> cache_; + std::map failed_; + + Font* try_get_face(const std::string& face_name) + { + if (face_name.empty() || failed_.count(face_name)) + return nullptr; + auto it = cache_.find(face_name); + if (it != cache_.end()) + return it->second.get(); + for (FontCreateFlags flags : + {FontCreateFlags::Embedded | FontCreateFlags::Subset, FontCreateFlags::Subset}) { + try { + auto font = std::make_unique(face_name, flags); + Font* result = font.get(); + cache_.emplace(face_name, std::move(font)); + return result; + } catch (const std::exception&) { + continue; + } + } + failed_[face_name] = true; + return nullptr; + } + + Font& get_face(const std::string& face_name) + { + Font* font = try_get_face(face_name); + if (!font) + throw CommandLineException( + "Could not create font \"" + face_name + + "\". Choose another family with --font-family or --list-font-families."); + return *font; + } + + Font* resolve_fallback(const std::string& sample, const InlineRun& run) + { + // Prefer the configured face if it already covers the text. + const FontFamily* preferred = run.code ? code_ : body_; + if (Font* face = try_get_face(preferred->face(run.bold, run.italic))) { + try { + if (face->is_text_representable(sample)) + return face; + } catch (const std::exception&) { + } + } + for (const auto& name : fallback_names_) { + const FontFamily* family = catalog_.find(name); + Font* font = family ? try_get_face(family->face(run.bold, run.italic)) : nullptr; + if (!font && family) + font = try_get_face(family->regular); + if (!font) + font = try_get_face(name); + if (!font) + continue; + try { + if (font->is_text_representable(sample)) + return font; + } catch (const std::exception&) { + } + } + return nullptr; + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Theme +// ───────────────────────────────────────────────────────────────────────────── + +struct Theme { + double page_width = 612; + double page_height = 792; + double margin = 72; + + static constexpr double body_size = 11; + static constexpr double body_line_height = 15; + static constexpr double paragraph_space_after = 7; + + static constexpr double heading_sizes[6] = {24, 20, 17, 14, 12, 11}; + static constexpr double heading_space_before[6] = {0, 14, 12, 10, 8, 8}; + static constexpr double heading_space_after[6] = {10, 8, 7, 6, 5, 5}; + + static constexpr double list_indent_per_level = 18; + static constexpr double list_marker_width = 22; + static constexpr double list_item_space_after = 2; + static constexpr double list_space_after = 5; + + static constexpr double code_size = 9.5; + static constexpr double code_line_height = 12.5; + static constexpr double code_title_size = 9; + static constexpr double code_title_line_height = 14; + static constexpr double code_padding = 8; + static constexpr double code_background_gray = 0.94; + static constexpr double inline_code_background_gray = 0.92; + static constexpr double inline_code_padding = 2; + static constexpr double code_space_before = 6; + static constexpr double code_space_after = 8; + + static constexpr double quote_indent = 18; + static constexpr double quote_space_before = 5; + static constexpr double quote_space_after = 8; + + static constexpr double rule_space_before = 8; + static constexpr double rule_space_after = 10; + + static constexpr double table_size = 9.5; + static constexpr double table_line_height = 12; + static constexpr double table_cell_padding = 4; + static constexpr double table_border_width = 0.5; + static constexpr double table_space_before = 8; + static constexpr double table_space_after = 9; + + double content_width() const { return page_width - 2 * margin; } + + static Theme create(const ConversionOptions& options) + { + Theme theme; + auto [w, h] = resolve_page_size(options.page_size); + if (options.orientation == Orientation::Landscape && h > w) + std::swap(w, h); + else if (options.orientation == Orientation::Portrait && w > h) + std::swap(w, h); + theme.page_width = w; + theme.page_height = h; + theme.margin = options.margin; + if (theme.content_width() < 72 || theme.page_height - 2 * theme.margin < 72) + throw CommandLineException("Page size leaves less than 72 points of usable space."); + return theme; + } +}; + +constexpr double Theme::heading_sizes[6]; +constexpr double Theme::heading_space_before[6]; +constexpr double Theme::heading_space_after[6]; + +// ───────────────────────────────────────────────────────────────────────────── +// Renderer +// ───────────────────────────────────────────────────────────────────────────── + +// One wrapped piece of a line: a styled run fragment with a resolved width. +struct LinePiece { + InlineRun run; + double width = 0; +}; + +struct WrappedLine { + std::vector pieces; + double width = 0; +}; + +class Renderer { +public: + Renderer(Document& document, const Theme& theme, const ConversionOptions& options, + FontSet& fonts) + : document_(document), theme_(theme), options_(options), fonts_(fonts) + { + root_ = std::make_unique(document.create_struct_tree_root()); + document_element_ = std::make_unique(root_->add_child("Document")); + + PDFString lang(options.language, document_, false); + document_.get_root()->put("Lang", lang); + + new_page(); + } + + void render(const std::vector& blocks) + { + for (const auto& block : blocks) { + switch (block.type) { + case BlockType::Heading: render_heading(block); break; + case BlockType::Paragraph: render_paragraph(block); break; + case BlockType::CodeBlock: render_code_block(block); break; + case BlockType::List: render_list(block); break; + case BlockType::BlockQuote: render_blockquote(block); break; + case BlockType::Table: render_table(block); break; + case BlockType::HorizontalRule: render_horizontal_rule(); break; + } + } + finish_page(); + } + + int page_count() const { return page_count_; } + +private: + Document& document_; + const Theme& theme_; + const ConversionOptions& options_; + FontSet& fonts_; + std::unique_ptr root_; + std::unique_ptr document_element_; + + std::optional page_; + std::optional content_; + double cursor_y_ = 0; + int page_count_ = 0; + + // ── Page management ──────────────────────────────────────── + + void finish_page() + { + if (page_) + page_->update_content(); + } + + void new_page() + { + finish_page(); + const int insert_after = page_count_ == 0 ? Document::before_first_page : page_count_ - 1; + page_.emplace(document_.create_page(insert_after, + Rect{0, 0, theme_.page_width, theme_.page_height})); + content_.emplace(page_->get_content()); + cursor_y_ = theme_.page_height - theme_.margin; + ++page_count_; + } + + bool at_top() const { return cursor_y_ >= theme_.page_height - theme_.margin - 0.01; } + + void ensure_space(double points) + { + if (cursor_y_ - points < theme_.margin && !at_top()) + new_page(); + } + + void move_down(double points) + { + if (at_top()) + return; + if (cursor_y_ - points < theme_.margin) + new_page(); + else + cursor_y_ -= points; + } + + // ── Tagged and artifact content ──────────────────────────── + + void add_tagged(Element& element, const std::string& tag, StructElement& owner) + { + Content inner; + inner.add_element(element); + PDFDict props(document_, false); // empty: the MCID is assigned by the library + Container container(tag, props, false); + container.set_content(inner); + content_->add_element(container); + owner.add_marked_content_ref(*page_, container); + } + + void add_artifact(Element& element) + { + Content inner; + inner.add_element(element); + PDFDict props(document_, false); + PDFName layout("Layout", document_, false); + props.put("Type", layout); + Container container("Artifact", props, false); + container.set_content(inner); + content_->add_element(container); + } + + // ── Measurement and wrapping ─────────────────────────────── + + double measure(const InlineRun& run, FontRole role, const std::string& text, double size) + { + if (text.empty()) + return 0; + Font& font = fonts_.resolve(run, role, text); + double measured; + try { + measured = font.measure_text_width(text, size); + } catch (const std::exception&) { + measured = static_cast(text.size()) * size * 0.55; + } + // Cap advances that some fonts report too wide for bullets and + // non-Latin scripts, mirroring the .NET sample's corrections. + double cap = 0; + size_t index = 0; + size_t code_points = 0; + bool needs_cap = true; + while (index < text.size()) { + const unsigned int cp = next_code_point(text, index); + ++code_points; + if (cp == 0x2022) + cap += size * 0.35; + else if (is_cjk_code_point(cp)) + cap += size; + else if (is_cyrillic_or_greek_code_point(cp)) + cap += estimate_cyrillic_or_greek_advance(cp, size); + else + needs_cap = false; + } + if (needs_cap && code_points > 0) + return std::min(measured, cap); + return measured; + } + + static double estimate_cyrillic_or_greek_advance(unsigned int cp, double size) + { + const bool upper = (cp >= 0x0400 && cp <= 0x042F) || (cp >= 0x0391 && cp <= 0x03A9); + static const unsigned int wide[] = {0x0436, 0x043C, 0x0448, 0x0449, 0x044E, 0x044B}; + static const unsigned int narrow[] = {0x0456, 0x0457, 0x0458, 0x03B9}; + for (unsigned int w : wide) + if (cp == w) + return size * 0.74; + for (unsigned int n : narrow) + if (cp == n) + return size * 0.32; + return upper ? size * 0.70 : size * 0.60; + } + + struct Token { + InlineRun run; // style + token text + bool space = false; + bool cjk = false; + }; + + static std::vector tokenize(const std::vector& runs, bool preformatted) + { + std::vector tokens; + for (const auto& run : runs) { + std::string word; + auto flush_word = [&]() { + if (word.empty()) + return; + Token token; + token.run = run; + token.run.text = word; + tokens.push_back(token); + word.clear(); + }; + size_t i = 0; + while (i < run.text.size()) { + size_t next = i; + const unsigned int cp = next_code_point(run.text, next); + if (cp == ' ' || cp == '\t' || cp == '\n' || cp == '\r') { + flush_word(); + // Preformatted text keeps every space; prose collapses runs. + if (preformatted || tokens.empty() || !tokens.back().space) { + Token space; + space.run = run; + space.run.text = " "; + space.space = true; + tokens.push_back(space); + } + } else if (is_cjk_code_point(cp)) { + flush_word(); + Token token; + token.run = run; + token.run.text = run.text.substr(i, next - i); + token.cjk = true; + tokens.push_back(token); + } else { + word += run.text.substr(i, next - i); + } + i = next; + } + flush_word(); + } + return tokens; + } + + // Splits a too-wide token into the widest prefix that fits, returning the + // prefix; `token_text` keeps the remainder. + std::string split_to_width(const InlineRun& run, FontRole role, std::string& token_text, + double size, double max_width) + { + std::vector boundaries; + size_t i = 0; + while (i < token_text.size()) { + next_code_point(token_text, i); + boundaries.push_back(i); + } + size_t low = 1, high = boundaries.size(), best = 1; + while (low <= high) { + const size_t mid = (low + high) / 2; + const double width = + measure(run, role, token_text.substr(0, boundaries[mid - 1]), size); + if (width <= max_width) { + best = mid; + low = mid + 1; + } else { + if (mid == 1) + break; + high = mid - 1; + } + } + const std::string prefix = token_text.substr(0, boundaries[best - 1]); + token_text.erase(0, boundaries[best - 1]); + return prefix; + } + + std::vector wrap(const std::vector& runs, FontRole role, double size, + double max_width, bool preformatted = false) + { + std::vector lines; + WrappedLine current; + + auto push_piece = [&](const InlineRun& run, double width) { + if (!current.pieces.empty() && current.pieces.back().run.same_style(run)) { + current.pieces.back().run.text += run.text; + current.pieces.back().width += width; + } else { + current.pieces.push_back({run, width}); + } + current.width += width; + }; + auto break_line = [&]() { + // Drop a trailing space. + if (!current.pieces.empty()) { + auto& last = current.pieces.back(); + while (!last.run.text.empty() && last.run.text.back() == ' ') { + last.run.text.pop_back(); + const double space_width = measure(last.run, role, " ", size); + last.width -= space_width; + current.width -= space_width; + } + if (last.run.text.empty()) + current.pieces.pop_back(); + } + lines.push_back(std::move(current)); + current = WrappedLine(); + }; + + for (auto& token : tokenize(runs, preformatted)) { + if (token.space) { + if (current.pieces.empty() && !preformatted) + continue; // no leading spaces in prose + push_piece(token.run, measure(token.run, role, " ", size)); + continue; + } + double width = measure(token.run, role, token.run.text, size); + if (current.width + width > max_width && !current.pieces.empty()) + break_line(); + if (width > max_width) { + std::string remaining = token.run.text; + while (!remaining.empty()) { + InlineRun piece_run = token.run; + piece_run.text = split_to_width(token.run, role, remaining, size, max_width); + if (piece_run.text.empty()) + break; // not even one character fits; give up on splitting + const double piece_width = measure(piece_run, role, piece_run.text, size); + push_piece(piece_run, piece_width); + if (!remaining.empty()) + break_line(); + } + continue; + } + push_piece(token.run, width); + } + if (!current.pieces.empty()) + break_line(); + if (lines.empty()) + lines.push_back(WrappedLine()); + return lines; + } + + // ── Drawing primitives ───────────────────────────────────── + + // Draws one styled piece at (x, baseline); returns the piece width. + double draw_piece(const LinePiece& piece, FontRole role, double size, double x, + double baseline, StructElement* owner, const std::string& tag) + { + if (piece.run.text.empty()) + return 0; + Font& font = fonts_.resolve(piece.run, role, piece.run.text); + + if (piece.run.code && !piece.run.text.empty() && tag != "Code") { + // Inline code: light gray backdrop behind the text. + Path backdrop; + GraphicState bg; + bg.set_fill_color(Color(Theme::inline_code_background_gray)); + backdrop.set_graphic_state(bg); + backdrop.set_paint_op(PathPaintOp::Fill); + backdrop.add_rect(Point(x - Theme::inline_code_padding, baseline - 0.28 * size), + piece.width + 2 * Theme::inline_code_padding, 1.20 * size); + add_artifact(backdrop); + } + + GraphicState gs; + gs.set_fill_color(piece.run.link ? Color(0.0, 0.0, 1.0) : Color(0.0)); + TextState ts; + Text text; + Matrix matrix(size, 0, 0, size, x, baseline); + TextRun run(piece.run.text, font, gs, ts, matrix); + text.add_run(run); + + if (owner) + add_tagged(text, tag, *owner); + else + add_artifact(text); + + if (piece.run.strike) { + Path stroke; + GraphicState sg; + sg.set_stroke_color(Color(0.0)); + sg.set_width(0.5); + stroke.set_graphic_state(sg); + stroke.set_paint_op(PathPaintOp::Stroke); + stroke.move_to(Point(x, baseline + 0.35 * size)); + stroke.add_line(Point(x + piece.width, baseline + 0.35 * size)); + add_artifact(stroke); + } + + if (piece.run.link && !piece.run.url.empty()) { + LinkAnnotation annotation( + *page_, Rect{x, baseline - 0.25 * size, x + piece.width, baseline + size}); + URIAction action(piece.run.url, false); + annotation.set_action(action); + annotation.set_border_style_width(0.0); + annotation.set_highlight(HighlightStyle::Invert); + annotation.set_contents(piece.run.text); + } + return piece.width; + } + + void draw_wrapped(const std::vector& runs, FontRole role, double size, + double line_height, double x, double max_width, StructElement& owner, + const std::string& tag) + { + for (const auto& line : wrap(runs, role, size, max_width)) { + ensure_space(line_height); + const double baseline = cursor_y_ - size; + double pen_x = x; + for (const auto& piece : line.pieces) + pen_x += draw_piece(piece, role, size, pen_x, baseline, &owner, tag); + cursor_y_ -= line_height; + } + } + + void draw_rule(double x1, double x2, double y, double width, double gray = 0.0) + { + Path line; + GraphicState gs; + gs.set_stroke_color(Color(gray)); + gs.set_width(width); + line.set_graphic_state(gs); + line.set_paint_op(PathPaintOp::Stroke); + line.move_to(Point(x1, y)); + line.add_line(Point(x2, y)); + add_artifact(line); + } + + void draw_filled_rect(double x, double y, double width, double height, double gray) + { + Path rect; + GraphicState gs; + gs.set_fill_color(Color(gray)); + rect.set_graphic_state(gs); + rect.set_paint_op(PathPaintOp::Fill); + rect.add_rect(Point(x, y), width, height); + add_artifact(rect); + } + + // ── Blocks ───────────────────────────────────────────────── + + static std::vector force_bold(const std::vector& runs) + { + std::vector out = runs; + for (auto& run : out) { + if (!run.code) + run.bold = true; + } + return out; + } + + void render_heading(const Block& block) + { + const int index = std::clamp(block.heading_level, 1, 6) - 1; + StructElement heading = + document_element_->add_child("H" + std::to_string(index + 1)); + move_down(Theme::heading_space_before[index]); + const double size = Theme::heading_sizes[index]; + draw_wrapped(force_bold(block.runs), FontRole::Heading, size, size * 1.25, theme_.margin, + theme_.content_width(), heading, "H" + std::to_string(index + 1)); + move_down(Theme::heading_space_after[index]); + } + + void render_paragraph(const Block& block) + { + StructElement paragraph = document_element_->add_child("P"); + draw_wrapped(block.runs, FontRole::Body, Theme::body_size, Theme::body_line_height, + theme_.margin, theme_.content_width(), paragraph, "P"); + move_down(Theme::paragraph_space_after); + } + + void render_list(const Block& block) + { + StructElement list = document_element_->add_child("L"); + for (const auto& item : block.items) { + StructElement li = list.add_child("LI"); + StructElement label = li.add_child("Lbl"); + StructElement body = li.add_child("LBody"); + + std::string marker; + if (item.task_state == 1) + marker = "[x]"; + else if (item.task_state == 0) + marker = "[ ]"; + else if (block.ordered) + marker = std::to_string(std::max(1, item.number)) + "."; + else + marker = u8"•"; + + const double marker_x = + theme_.margin + item.level * Theme::list_indent_per_level; + const double text_x = marker_x + Theme::list_marker_width; + const double text_width = theme_.page_width - theme_.margin - text_x; + + const auto lines = + wrap(item.runs, FontRole::Body, Theme::body_size, text_width); + bool first = true; + for (const auto& line : lines) { + ensure_space(Theme::body_line_height); + const double baseline = cursor_y_ - Theme::body_size; + if (first) { + InlineRun marker_run; + marker_run.text = marker; + LinePiece marker_piece{ + marker_run, + measure(marker_run, FontRole::Body, marker, Theme::body_size)}; + draw_piece(marker_piece, FontRole::Body, Theme::body_size, marker_x, baseline, + &label, "Lbl"); + first = false; + } + double pen_x = text_x; + for (const auto& piece : line.pieces) + pen_x += draw_piece(piece, FontRole::Body, Theme::body_size, pen_x, baseline, + &body, "LBody"); + cursor_y_ -= Theme::body_line_height; + } + move_down(Theme::list_item_space_after); + } + move_down(Theme::list_space_after); + } + + static std::string language_label(const std::string& language) + { + static const std::map labels = { + {"cs", "C#"}, {"csharp", "C#"}, {"cpp", "C++"}, {"c++", "C++"}, + {"c", "C"}, {"py", "Python"}, {"python", "Python"}, {"js", "JavaScript"}, + {"javascript", "JavaScript"}, {"ts", "TypeScript"}, {"typescript", "TypeScript"}, + {"ps", "PowerShell"}, {"pwsh", "PowerShell"}, {"powershell", "PowerShell"}, + {"sh", "Shell"}, {"bash", "Shell"}, {"shell", "Shell"}, {"json", "JSON"}, + {"xml", "XML"}, {"html", "HTML"}, {"yaml", "YAML"}, {"yml", "YAML"}, + {"sql", "SQL"}, {"java", "Java"}, {"kotlin", "Kotlin"}, {"go", "Go"}, + {"rust", "Rust"}, {"rb", "Ruby"}, {"ruby", "Ruby"}, {"md", "Markdown"}, + {"markdown", "Markdown"}, + }; + auto it = labels.find(to_lower(language)); + return it != labels.end() ? it->second : language; + } + + void render_code_block(const Block& block) + { + StructElement code = document_element_->add_child("Code"); + move_down(Theme::code_space_before); + + const double width = theme_.content_width(); + const double text_x = theme_.margin + Theme::code_padding; + const double text_width = width - 2 * Theme::code_padding; + + if (!block.language.empty()) { + ensure_space(Theme::code_title_line_height); + draw_filled_rect(theme_.margin, cursor_y_ - Theme::code_title_line_height, width, + Theme::code_title_line_height, Theme::code_background_gray); + InlineRun title; + title.text = " " + language_label(block.language); + title.bold = true; + const double baseline = cursor_y_ - Theme::code_title_size; + LinePiece piece{title, measure(title, FontRole::Code, title.text, + Theme::code_title_size)}; + draw_piece(piece, FontRole::Code, Theme::code_title_size, text_x, baseline, &code, + "Code"); + cursor_y_ -= Theme::code_title_line_height; + } + + // Top padding strip. + ensure_space(Theme::code_padding); + draw_filled_rect(theme_.margin, cursor_y_ - Theme::code_padding, width, + Theme::code_padding, Theme::code_background_gray); + cursor_y_ -= Theme::code_padding; + + const std::vector lines = + block.code_lines.empty() ? std::vector{""} : block.code_lines; + for (const auto& source_line : lines) { + InlineRun run; + run.text = source_line; + run.code = true; + std::vector runs{run}; + for (auto& line : + wrap(runs, FontRole::Code, Theme::code_size, text_width, true)) { + ensure_space(Theme::code_line_height); + draw_filled_rect(theme_.margin, cursor_y_ - Theme::code_line_height, width, + Theme::code_line_height, Theme::code_background_gray); + const double baseline = cursor_y_ - Theme::code_size; + double pen_x = text_x; + for (const auto& piece : line.pieces) + pen_x += draw_piece(piece, FontRole::Code, Theme::code_size, pen_x, baseline, + &code, "Code"); + cursor_y_ -= Theme::code_line_height; + } + } + + // Bottom padding strip. + ensure_space(Theme::code_padding); + draw_filled_rect(theme_.margin, cursor_y_ - Theme::code_padding, width, + Theme::code_padding, Theme::code_background_gray); + cursor_y_ -= Theme::code_padding; + + move_down(Theme::code_space_after); + } + + void render_blockquote(const Block& block) + { + StructElement quote = document_element_->add_child("BlockQuote"); + move_down(Theme::quote_space_before); + + std::vector runs = block.runs; + for (auto& run : runs) { + if (!run.code) + run.italic = true; + } + + const double text_x = theme_.margin + Theme::quote_indent; + const double text_width = theme_.page_width - theme_.margin - text_x; + bool first = true; + for (const auto& line : wrap(runs, FontRole::Body, Theme::body_size, text_width)) { + ensure_space(Theme::body_line_height); + const double baseline = cursor_y_ - Theme::body_size; + if (first) { + InlineRun marker; + marker.text = ">"; + marker.italic = true; + LinePiece piece{marker, + measure(marker, FontRole::Body, ">", Theme::body_size)}; + draw_piece(piece, FontRole::Body, Theme::body_size, theme_.margin, baseline, + nullptr, ""); + first = false; + } + double pen_x = text_x; + for (const auto& piece : line.pieces) + pen_x += draw_piece(piece, FontRole::Body, Theme::body_size, pen_x, baseline, + "e, "BlockQuote"); + cursor_y_ -= Theme::body_line_height; + } + move_down(Theme::quote_space_after); + } + + void render_table(const Block& block) + { + if (block.header_cells.empty()) + return; + StructElement table = document_element_->add_child("Table"); + move_down(Theme::table_space_before); + + const size_t columns = block.header_cells.size(); + const double column_width = theme_.content_width() / static_cast(columns); + const double cell_text_width = column_width - 2 * Theme::table_cell_padding; + + bool first_row_on_page = true; + auto draw_row = [&](const std::vector>& cells, bool header, + StructElement& row_parent) { + StructElement row = row_parent.add_child("TR"); + + // Wrap every cell first so the row height is known. + std::vector> wrapped; + size_t max_lines = 1; + for (size_t c = 0; c < columns; ++c) { + std::vector runs = + c < cells.size() ? cells[c] : std::vector{}; + if (header) + runs = force_bold(runs); + wrapped.push_back( + wrap(runs, FontRole::Body, Theme::table_size, cell_text_width)); + max_lines = std::max(max_lines, wrapped.back().size()); + } + const double row_height = static_cast(max_lines) * Theme::table_line_height + + 2 * Theme::table_cell_padding; + if (cursor_y_ - row_height < theme_.margin && !at_top()) { + new_page(); + first_row_on_page = true; + } + if (first_row_on_page) { + draw_rule(theme_.margin, theme_.margin + column_width * columns, cursor_y_, + Theme::table_border_width); + first_row_on_page = false; + } + + const double row_top = cursor_y_; + for (size_t c = 0; c < columns; ++c) { + StructElement cell = row.add_child(header ? "TH" : "TD"); + const double cell_x = theme_.margin + column_width * static_cast(c); + double line_y = row_top - Theme::table_cell_padding; + for (const auto& line : wrapped[c]) { + const double baseline = line_y - Theme::table_size; + double start_x = cell_x + Theme::table_cell_padding; + const ColumnAlignment alignment = + c < block.alignments.size() ? block.alignments[c] + : ColumnAlignment::Left; + if (alignment == ColumnAlignment::Right) + start_x = cell_x + column_width - Theme::table_cell_padding - line.width; + else if (alignment == ColumnAlignment::Center) + start_x = cell_x + (column_width - line.width) / 2; + double pen_x = start_x; + for (const auto& piece : line.pieces) + pen_x += draw_piece(piece, FontRole::Body, Theme::table_size, pen_x, + baseline, &cell, header ? "TH" : "TD"); + line_y -= Theme::table_line_height; + } + } + + // Grid: verticals for this row plus the bottom border. + for (size_t c = 0; c <= columns; ++c) { + const double x = theme_.margin + column_width * static_cast(c); + Path line; + GraphicState gs; + gs.set_stroke_color(Color(0.0)); + gs.set_width(Theme::table_border_width); + line.set_graphic_state(gs); + line.set_paint_op(PathPaintOp::Stroke); + line.move_to(Point(x, row_top)); + line.add_line(Point(x, row_top - row_height)); + add_artifact(line); + } + draw_rule(theme_.margin, theme_.margin + column_width * columns, + row_top - row_height, Theme::table_border_width); + cursor_y_ = row_top - row_height; + }; + + draw_row(block.header_cells, true, table); + for (const auto& row : block.rows) + draw_row(row, false, table); + + move_down(Theme::table_space_after); + } + + void render_horizontal_rule() + { + move_down(Theme::rule_space_before); + ensure_space(12); + draw_rule(theme_.margin, theme_.page_width - theme_.margin, cursor_y_ - 4, 0.75); + move_down(Theme::rule_space_after + 8); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Self-tests: exercise the parser and option handling without creating a PDF. +// ───────────────────────────────────────────────────────────────────────────── + +int run_self_tests() +{ + int failures = 0; + auto require = [&](bool condition, const std::string& what) { + if (!condition) { + std::cout << "FAILED: " << what << std::endl; + ++failures; + } + }; + + MarkdownParser parser; + const auto find_run = [](const std::vector& blocks, + const std::function& predicate) + -> const InlineRun* { + for (const auto& block : blocks) { + for (const auto& run : block.runs) + if (predicate(run)) + return &run; + for (const auto& item : block.items) + for (const auto& run : item.runs) + if (predicate(run)) + return &run; + } + return nullptr; + }; + + { + const auto blocks = parser.parse( + "# Title\n\nSome **bold**, *italic*, ***both***, ~~gone~~, and `code`.\n", false); + require(blocks.size() == 2, "heading + paragraph block count"); + require(blocks[0].type == BlockType::Heading && blocks[0].heading_level == 1, + "ATX heading level 1"); + require(find_run(blocks, [](const InlineRun& r) { return r.bold && !r.italic; }) != nullptr, + "bold run parsed"); + require(find_run(blocks, [](const InlineRun& r) { return r.italic && !r.bold; }) != nullptr, + "italic run parsed"); + require(find_run(blocks, [](const InlineRun& r) { return r.bold && r.italic; }) != nullptr, + "bold-italic run parsed"); + require(find_run(blocks, [](const InlineRun& r) { return r.strike; }) != nullptr, + "strikethrough run parsed"); + require(find_run(blocks, [](const InlineRun& r) { return r.code; }) != nullptr, + "inline code run parsed"); + } + { + const auto blocks = parser.parse( + "See [Community Guide][guide] and or " + "https://example.org/trail.\n\n[guide]: https://docs.example.com\n", + false); + const InlineRun* reference = find_run( + blocks, [](const InlineRun& r) { return r.link && r.text == "Community Guide"; }); + require(reference && reference->url == "https://docs.example.com", + "reference link resolves URL, shows anchor text"); + require(find_run(blocks, [](const InlineRun& r) { + return r.link && r.url == "https://example.com"; + }) != nullptr, + "autolink parsed"); + require(find_run(blocks, [](const InlineRun& r) { + return r.link && r.url == "https://example.org/trail"; + }) != nullptr, + "bare URL parsed with trailing punctuation trimmed"); + } + { + const auto blocks = parser.parse("- [x] done\n- [ ] todo\n\n1. one\n2. two\n", false); + require(blocks.size() == 2, "task list and ordered list separate blocks"); + require(blocks[0].type == BlockType::List && !blocks[0].ordered && + blocks[0].items.size() == 2 && blocks[0].items[0].task_state == 1, + "task list checked state"); + require(blocks[1].type == BlockType::List && blocks[1].ordered && + blocks[1].items.size() == 2, + "ordered list items"); + } + { + const auto blocks = + parser.parse("> A quote with a [link](https://example.net) inside.\n", false); + require(blocks.size() == 1 && blocks[0].type == BlockType::BlockQuote, + "blockquote block"); + require(find_run(blocks, [](const InlineRun& r) { + return r.link && r.url == "https://example.net"; + }) != nullptr, + "link inside blockquote"); + } + { + const auto blocks = + parser.parse("```PowerShell\nGet-ChildItem\nWrite-Output done\n```\n", false); + require(blocks.size() == 1 && blocks[0].type == BlockType::CodeBlock && + blocks[0].language == "PowerShell" && blocks[0].code_lines.size() == 2, + "fenced code block with language"); + } + { + const auto blocks = parser.parse( + "| Name | Count |\n| --- | ---: |\n| a | 1 |\n| b | 2 |\n", false); + require(blocks.size() == 1 && blocks[0].type == BlockType::Table, + "table recognized"); + if (!blocks.empty() && blocks[0].type == BlockType::Table) { + require(blocks[0].header_cells.size() == 2, "table column count"); + require(blocks[0].rows.size() == 2, "table row count"); + require(blocks[0].alignments[0] == ColumnAlignment::Left && + blocks[0].alignments[1] == ColumnAlignment::Right, + "table alignments"); + } + } + { + const auto blocks = parser.parse("Intro\n\n---\n\nSetext\n------\n", false); + bool saw_rule = false, saw_setext = false; + for (const auto& block : blocks) { + if (block.type == BlockType::HorizontalRule) + saw_rule = true; + if (block.type == BlockType::Heading && block.heading_level == 2) + saw_setext = true; + } + require(saw_rule, "horizontal rule"); + require(saw_setext, "setext heading level 2"); + } + { + const auto blocks = + parser.parse("Use TRAIL_MAP_VERSION and _italic_ plus **bold**.\n", false); + require(find_run(blocks, [](const InlineRun& r) { + return !r.italic && r.text.find("TRAIL_MAP_VERSION") != std::string::npos; + }) != nullptr, + "underscore identifier not italicized"); + require(find_run(blocks, [](const InlineRun& r) { + return r.italic && r.text == "italic"; + }) != nullptr, + "underscore emphasis still works"); + } + { + const auto blocks = parser.parse( + "

HTML heading

\n

Bold & italic, " + "mono, gone, site." + "\"badge\"

\n", + false); + require(!blocks.empty() && blocks[0].type == BlockType::Heading && + blocks[0].heading_level == 2, + "HTML h2 becomes heading"); + require(find_run(blocks, [](const InlineRun& r) { return r.bold; }) != nullptr, + "HTML strong becomes bold"); + require(find_run(blocks, [](const InlineRun& r) { return r.code; }) != nullptr, + "HTML code becomes inline code"); + require(find_run(blocks, [](const InlineRun& r) { return r.strike; }) != nullptr, + "HTML del becomes strikethrough"); + require(find_run(blocks, [](const InlineRun& r) { + return r.link && r.url == "https://example.com"; + }) != nullptr, + "HTML anchor becomes link"); + require(find_run(blocks, [](const InlineRun& r) { + return r.text.find("[Image omitted: badge]") != std::string::npos; + }) != nullptr, + "HTML img becomes omission note"); + require(find_run(blocks, [](const InlineRun& r) { + return r.text.find('&') != std::string::npos; + }) != nullptr, + "HTML entity decoded"); + require(find_run(blocks, [](const InlineRun& r) { + return r.text.find("