lib/pdf/src/content.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_arena = @import("alloc_arena");
3
4 const font = @import("font.zig");
5 const object = @import("object.zig");
6
7 const Piece = union(enum) {
8 text: object.String,
9 space,
10 };
11
12 pub fn extractTextAppend(allocator: std.mem.Allocator, out: *std.ArrayList(u8), content: []const u8, fonts: font.Set) !void {
13 var parser = object.Parser.init(content, 0);
14 var pending_strings: std.ArrayList(Piece) = .empty;
15 defer pending_strings.deinit(allocator);
16 var arena_state = alloc_arena.Arena.init(allocator);
17 defer arena_state.deinit();
18 const arena = arena_state.allocator();
19 var last_name: ?[]const u8 = null;
20 var current_font: ?*const font.Font = null;
21 for (content) |_| {
22 parser.skipWhitespace();
23 if (parser.pos >= content.len) break;
24 const byte = content[parser.pos];
25 if (byte == '/' or byte == '(' or byte == '<' or byte == '[' or std.ascii.isDigit(byte) or byte == '+' or byte == '-' or byte == '.') {
26 const value = parser.parseValue(arena) catch {
27 parser.pos += 1;
28 continue;
29 };
30 switch (value) {
31 .name => |name| last_name = name,
32 else => {},
33 }
34 try collectStrings(allocator, &pending_strings, value);
35 continue;
36 }
37 const start = parser.pos;
38 while (parser.pos < content.len and !object.delimiterOrWhitespace(content[parser.pos])) parser.pos += 1;
39 if (parser.pos == start) {
40 parser.pos += 1;
41 continue;
42 }
43 const operator = content[start..parser.pos];
44 if (std.mem.eql(u8, operator, "Tf")) {
45 current_font = if (last_name) |name| fonts.get(name) else null;
46 }
47 try applyOperator(allocator, out, &pending_strings, operator, current_font);
48 }
49 }
50
51 fn collectStrings(allocator: std.mem.Allocator, pending: *std.ArrayList(Piece), value: object.Value) !void {
52 switch (value) {
53 .string => |string| try pending.append(allocator, .{ .text = string }),
54 .array => |items| {
55 for (items) |item| {
56 switch (item) {
57 .string => |string| try pending.append(allocator, .{ .text = string }),
58 .integer => |kern| if (kern <= -space_kerning_threshold) try pending.append(allocator, .space),
59 .real => |kern| if (kern <= -@as(f64, space_kerning_threshold)) try pending.append(allocator, .space),
60 else => {},
61 }
62 }
63 },
64 else => {},
65 }
66 }
67
68 const space_kerning_threshold = 180;
69
70 fn applyOperator(allocator: std.mem.Allocator, out: *std.ArrayList(u8), pending: *std.ArrayList(Piece), operator: []const u8, current_font: ?*const font.Font) !void {
71 if (std.mem.eql(u8, operator, "Tj") or std.mem.eql(u8, operator, "'") or std.mem.eql(u8, operator, "\"") or std.mem.eql(u8, operator, "TJ")) {
72 if (std.mem.eql(u8, operator, "'") or std.mem.eql(u8, operator, "\"")) {
73 try breakLine(allocator, out);
74 }
75 for (pending.items) |piece| {
76 switch (piece) {
77 .text => |string| try appendString(allocator, out, string, current_font),
78 .space => {
79 if (out.items.len > 0 and out.items[out.items.len - 1] != ' ' and out.items[out.items.len - 1] != '\n') {
80 try out.append(allocator, ' ');
81 }
82 },
83 }
84 }
85 pending.clearRetainingCapacity();
86 return;
87 }
88 if (std.mem.eql(u8, operator, "Td") or std.mem.eql(u8, operator, "TD") or std.mem.eql(u8, operator, "T*") or std.mem.eql(u8, operator, "ET")) {
89 try breakLine(allocator, out);
90 pending.clearRetainingCapacity();
91 return;
92 }
93 pending.clearRetainingCapacity();
94 }
95
96 fn appendString(allocator: std.mem.Allocator, out: *std.ArrayList(u8), string: object.String, current_font: ?*const font.Font) !void {
97 var raw = std.ArrayList(u8).empty;
98 defer raw.deinit(allocator);
99 switch (string.kind) {
100 .literal => try object.decodeLiteralAppend(allocator, &raw, string.raw),
101 .hex => try object.decodeHexAppend(allocator, &raw, string.raw),
102 }
103 try font.appendText(current_font, allocator, out, raw.items);
104 }
105
106 fn breakLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8)) !void {
107 if (out.items.len == 0) return;
108 if (out.items[out.items.len - 1] == '\n') return;
109 try out.append(allocator, '\n');
110 }
111
112 test "content extraction reads show operators lines and tj arrays" {
113 var out = std.ArrayList(u8).empty;
114 defer out.deinit(std.testing.allocator);
115 const content =
116 "BT /F1 12 Tf 72 700 Td (Hello, ) Tj (world) Tj T* (second line) Tj " ++
117 "0 -14 Td [(kerned) -250 (array) 12 (Close)] TJ ET";
118 try extractTextAppend(std.testing.allocator, &out, content, .{});
119 try std.testing.expectEqualStrings("Hello, world\nsecond line\nkerned arrayClose\n", out.items);
120 }
121
122 test "content extraction survives unknown operators and hex strings" {
123 var out = std.ArrayList(u8).empty;
124 defer out.deinit(std.testing.allocator);
125 try extractTextAppend(std.testing.allocator, &out, "q 1 0 0 1 10 10 cm BT <48690A> Tj ET Q", .{});
126 try std.testing.expectEqualStrings("Hi\n", out.items);
127 }
128
129 test "content extraction maps codes through the selected font" {
130 const cmap = @import("cmap.zig");
131 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
132 defer arena_state.deinit();
133 const map = try cmap.parseAlloc(
134 arena_state.allocator(),
135 "1 begincodespacerange <00> <FF> endcodespacerange " ++
136 "1 beginbfrange <20> <7E> <0020> endbfrange " ++
137 "1 beginbfchar <02> <00660069> endbfchar",
138 );
139 const mapped = font.Font{ .map = map };
140 const plain = font.Font{};
141 const set = font.Set{ .entries = &.{
142 .{ .name = "F1", .font = &mapped },
143 .{ .name = "F2", .font = &plain },
144 } };
145 var out = std.ArrayList(u8).empty;
146 defer out.deinit(std.testing.allocator);
147 try extractTextAppend(
148 std.testing.allocator,
149 &out,
150 "BT /F1 9 Tf (Arti\\002cial) Tj /F2 9 Tf ( raw\\002) Tj ET",
151 set,
152 );
153 try std.testing.expectEqualStrings("Artificial raw\x02\n", out.items);
154 }