// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-FileCopyrightText: © 2000 Keith Packard // SPDX-License-Identifier: MIT AND HPND-sell-variant //! Font names, the strings `fc-match` takes: `DejaVu Sans-12:bold`, from //! `fcname.c`. //! //! A name is a comma-separated list of families, then optionally a dash //! and a comma-separated list of sizes, then any number of `:object=value` //! (or `:object_value`) elements and bare `:constant`s. A backslash escapes //! the next character. const std = @import("std"); const Allocator = std.mem.Allocator; const object = @import("object.zig"); const value = @import("value.zig"); const str = @import("str.zig"); const CharSet = @import("CharSet.zig"); const LangSet = @import("LangSet.zig"); const Pattern = @import("Pattern.zig"); const Object = object.Object; const Value = value.Value; pub const ParseError = Allocator.Error || error{InvalidName}; /// `FcNameBool`: `t`, `y`, `1`, `on` are true; `f`, `n`, `0`, `off` false; /// `d`, `x`, `2`, `or` don't-care. Only the first one or two characters /// are looked at. pub fn parseBool(v: []const u8) ?value.Bool { if (v.len == 0) return null; const c0 = str.toLower(v[0]); if (c0 == 't' or c0 == 'y' or c0 == '1') return .true; if (c0 == 'f' or c0 == 'n' or c0 == '0') return .false; if (c0 == 'd' or c0 == 'x' or c0 == '2') return .dontcare; if (c0 == 'o' and v.len > 1) { const c1 = str.toLower(v[1]); if (c1 == 'n') return .true; if (c1 == 'f') return .false; if (c1 == 'r') return .dontcare; } return null; } /// `FcNameConvert`: the text of a value, read as the type `o` holds. The /// result may point into `arena`, and is `.void` if the text does not read /// as that type. pub fn convert(arena: Allocator, o: Object, text: []const u8) Allocator.Error!Value { switch (o.valueType()) { .integer => { if (object.constantFor(text, o)) |c| return .{ .integer = c.value }; return .{ .integer = str.atoi(text) }; }, .string => return .{ .string = try arena.dupe(u8, text) }, .bool => return .{ .bool = parseBool(text) orelse .false }, .double => return .{ .double = str.strtod(text).value }, .matrix => { // sscanf("%lg %lg %lg %lg"): as many as parse, the rest left // at the identity. var m: value.Matrix = .identity; var rest = text; const fields = [_]*f64{ &m.xx, &m.xy, &m.yx, &m.yy }; for (fields) |f| { const r = str.strtod(rest); if (r.len == 0) break; f.* = r.value; rest = rest[r.len..]; } return .{ .matrix = m }; }, .charset => { const cs = try CharSet.parse(arena, text) orelse return .void; const p = try arena.create(CharSet); p.* = cs; return .{ .charset = p }; }, .langset => { const p = try arena.create(LangSet); p.* = try LangSet.parse(arena, text); return .{ .langset = p }; }, .range => return convertRange(o, text), .unknown, .void => return .void, } } /// A range is written `[begin end]`, with numbers or constants for the /// ends; anything else is read as a single number or constant. fn convertRange(o: Object, text: []const u8) Value { if (text.len > 0 and text[0] == '[') { const b = str.strtod(text[1..]); if (b.len > 0) { var rest = text[1 + b.len ..]; rest = std.mem.trimStart(u8, rest, " \t\n\r"); const e = str.strtod(rest); if (e.len > 0 and e.len < rest.len and rest[e.len] == ']') return .{ .range = .{ .begin = b.value, .end = e.value } }; // sscanf's "[%lg %lg]" stops matching at the "]", but has // already read both numbers, which is all it reports. if (e.len > 0) return .{ .range = .{ .begin = b.value, .end = e.value } }; } // "[%s %[^]]]": two words, both constants. const inner = text[1..]; var words = std.mem.tokenizeAny(u8, inner, " \t\n\r"); if (words.next()) |first| { if (words.next()) |second_raw| { const close = std.mem.findScalar(u8, second_raw, ']'); const second = if (close) |c| second_raw[0..c] else second_raw; const sc = object.constantFor(first, o); const ec = object.constantFor(second, o); if (sc != null and ec != null) return .{ .range = .{ .begin = @floatFromInt(sc.?.value), .end = @floatFromInt(ec.?.value) } }; } } } if (object.constantFor(text, o)) |c| return .{ .double = @floatFromInt(c.value) }; const d = str.strtod(text); if (d.len != text.len) return .void; return .{ .double = d.value }; } /// `FcNameFindNext`: skips leading blanks, then copies up to the next /// unescaped delimiter, dropping backslashes. Returns the token, the /// delimiter that ended it (0 at the end), and the rest. fn findNext(buf: []u8, s: []const u8, delims: []const u8) struct { token: []const u8, delim: u8, rest: []const u8 } { var i: usize = 0; while (i < s.len and std.ascii.isWhitespace(s[i])) i += 1; var n: usize = 0; var delim: u8 = 0; while (i < s.len) { var c = s[i]; if (c == '\\') { i += 1; if (i >= s.len) break; c = s[i]; } else if (std.mem.findScalar(u8, delims, c) != null) { delim = c; i += 1; break; } i += 1; buf[n] = c; n += 1; } return .{ .token = buf[0..n], .delim = delim, .rest = s[i..] }; } /// `FcNameParse`. Fails, as fontconfig does, if an element names an object /// whose value cannot be stored — any object fontconfig does not know, for /// one. pub fn parse(gpa: Allocator, name: []const u8) ParseError!Pattern { var pat: Pattern = .init(gpa); errdefer pat.deinit(); const arena = pat.allocator(); const buf = try arena.alloc(u8, name.len); var rest = name; var delim: u8 = 0; while (true) { const t = findNext(buf, rest, "-,:"); rest = t.rest; delim = t.delim; if (t.token.len > 0) try pat.addString(.family, t.token); if (delim != ',') break; } if (delim == '-') { while (true) { const t = findNext(buf, rest, "-,:"); rest = t.rest; delim = t.delim; const d = str.strtod(t.token); if (d.len > 0) try pat.addDouble(.size, d.value); if (delim != ',') break; } } while (delim == ':') { const t = findNext(buf, rest, "=_:"); rest = t.rest; delim = t.delim; if (t.token.len == 0) continue; if (delim == '=' or delim == '_') { const o = Object.fromName(try arena.dupe(u8, t.token)); while (true) { const v = findNext(buf, rest, ":,"); rest = v.rest; delim = v.delim; const converted = try convert(arena, o, v.token); if (!try pat.addBorrowed(o, converted, .strong, true)) return error.InvalidName; if (delim != ',') break; } } else if (object.constant(t.token)) |c| { const o: Object = .{ .builtin = c.object }; switch (c.object.valueType()) { .integer, .double, .range => try pat.addInteger(c.object, c.value), .bool => _ = try pat.add(o, .{ .bool = @fromBackingInt(@intCast(c.value)) }), else => {}, } } } return pat; } const testing = std.testing; fn expectRoundTrip(name: []const u8, expected: []const u8) !void { var p = try parse(testing.allocator, name); defer p.deinit(); var out: std.Io.Writer.Allocating = .init(testing.allocator); defer out.deinit(); try p.format(&out.writer); try testing.expectEqualStrings(expected, out.written()); } test "parse names" { try expectRoundTrip("DejaVu Sans-12:bold", "DejaVu Sans-12:weight=200"); try expectRoundTrip("Times,Liberation Serif:italic", "Times,Liberation Serif:slant=100"); try expectRoundTrip("monospace:weight=bold:slant=italic", "monospace:slant=100:weight=200"); try expectRoundTrip(":lang=ja", ":lang=ja"); try expectRoundTrip("Foo\\-Bar", "Foo\\-Bar"); try expectRoundTrip("sans:weight=[light bold]", "sans:weight=[50 200]"); try expectRoundTrip("sans:weight=[50 100]", "sans:weight=[50 100]"); try expectRoundTrip("sans:antialias=false:pixelsize=10.5", "sans:pixelsize=10.5:antialias=False"); try expectRoundTrip("sans:charset=41-43", "sans:charset=41-43"); try expectRoundTrip("sans:normal", "sans"); try expectRoundTrip(":outline", ":outline=True"); } test "unknown objects make the name invalid" { try testing.expectError(error.InvalidName, parse(testing.allocator, "sans:frobnicate=1")); try testing.expectError(error.InvalidName, parse(testing.allocator, "sans:weight=heavyish")); } test "parse booleans" { try testing.expectEqual(value.Bool.true, parseBool("on").?); try testing.expectEqual(value.Bool.false, parseBool("Off").?); try testing.expectEqual(value.Bool.dontcare, parseBool("dontcare").?); try testing.expectEqual(@as(?value.Bool, null), parseBool("maybe")); }