User commited on
Commit
97c95a9
·
1 Parent(s): 3dd5c9a

Fix Burmese PDF rendering

Browse files
Dockerfile CHANGED
@@ -26,7 +26,7 @@ ENV ASPNETCORE_ENVIRONMENT=Production
26
  EXPOSE 7860
27
 
28
  RUN apt-get update \
29
- && apt-get install -y --no-install-recommends nginx ca-certificates \
30
  && rm -rf /var/lib/apt/lists/*
31
 
32
  COPY --from=build /app/publish/api /app/api
 
26
  EXPOSE 7860
27
 
28
  RUN apt-get update \
29
+ && apt-get install -y --no-install-recommends nginx ca-certificates fonts-noto-core \
30
  && rm -rf /var/lib/apt/lists/*
31
 
32
  COPY --from=build /app/publish/api /app/api
TaskTrackingSystem.Shared/SimplePdfReportBuilder.cs CHANGED
@@ -1,18 +1,23 @@
1
- using System.Text;
 
 
2
 
3
  namespace TaskTrackingSystem.Shared;
4
 
5
  public static class SimplePdfReportBuilder
6
  {
7
- private const int PageWidth = 612;
8
- private const int PageHeight = 792;
9
- private const int LeftMargin = 48;
10
- private const int TopMargin = 52;
11
- private const int BottomMargin = 48;
12
- private const int TitleFontSize = 16;
13
- private const int BodyFontSize = 10;
14
- private const int LineSpacing = 14;
15
  private const int BodyLinesPerPage = 42;
 
 
 
16
 
17
  public static byte[] BuildTableReport(
18
  string title,
@@ -24,22 +29,16 @@ public static class SimplePdfReportBuilder
24
 
25
  if (summaryLines != null)
26
  {
27
- foreach (var line in summaryLines.Where(line => !string.IsNullOrWhiteSpace(line)))
28
- {
29
- lines.AddRange(WrapLine(line.Trim()));
30
- }
31
  }
32
 
33
  if (headers.Count > 0)
34
  {
35
  lines.Add(string.Join(" | ", headers));
36
- lines.Add(new string('-', Math.Min(96, Math.Max(24, headers.Sum(h => h.Length + 3)))));
37
  }
38
 
39
- foreach (var row in rows)
40
- {
41
- lines.AddRange(WrapLine(string.Join(" | ", row.Select(NormalizeCell))));
42
- }
43
 
44
  if (lines.Count == 0)
45
  {
@@ -51,8 +50,13 @@ public static class SimplePdfReportBuilder
51
 
52
  public static byte[] BuildTextReport(string title, IEnumerable<string> bodyLines)
53
  {
 
 
 
 
 
54
  var normalizedLines = bodyLines
55
- .SelectMany(line => WrapLine(line))
56
  .ToList();
57
 
58
  var pages = normalizedLines
@@ -62,128 +66,185 @@ public static class SimplePdfReportBuilder
62
 
63
  if (pages.Count == 0)
64
  {
65
- pages.Add(new List<string> { "No data available." });
66
  }
67
 
68
- var objects = new List<string>();
69
- objects.Add("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
70
-
71
- var pageObjectIds = new List<int>();
72
- var contentObjectIds = new List<int>();
73
- var nextObjectId = 4;
74
 
75
- foreach (var _ in pages)
76
  {
77
- pageObjectIds.Add(nextObjectId++);
78
- contentObjectIds.Add(nextObjectId++);
 
79
  }
80
 
81
- var pagesKids = string.Join(" ", pageObjectIds.Select(id => $"{id} 0 R"));
82
- objects.Add($"2 0 obj\n<< /Type /Pages /Kids [{pagesKids}] /Count {pages.Count} >>\nendobj\n");
83
- objects.Add("3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
84
 
85
- for (var pageIndex = 0; pageIndex < pages.Count; pageIndex++)
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  {
87
- var pageObjectId = pageObjectIds[pageIndex];
88
- var contentObjectId = contentObjectIds[pageIndex];
89
- var pageLines = pages[pageIndex];
90
- var content = BuildContentStream(title, pageLines, pageIndex + 1, pages.Count);
91
- var contentBytes = Encoding.ASCII.GetBytes(content);
92
-
93
- objects.Add(
94
- $"{pageObjectId} 0 obj\n" +
95
- $"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PageWidth} {PageHeight}] /Resources << /Font << /F1 3 0 R >> >> /Contents {contentObjectId} 0 R >>\n" +
96
- "endobj\n");
97
-
98
- objects.Add(
99
- $"{contentObjectId} 0 obj\n" +
100
- $"<< /Length {contentBytes.Length} >>\n" +
101
- "stream\n" +
102
- content +
103
- "\nendstream\nendobj\n");
104
- }
105
 
106
- var buffer = new StringBuilder();
107
- buffer.Append("%PDF-1.4\n");
 
 
 
 
108
 
109
- var offsets = new List<int> { 0 };
110
- foreach (var obj in objects)
 
111
  {
112
- offsets.Add(Encoding.ASCII.GetByteCount(buffer.ToString()));
113
- buffer.Append(obj);
114
  }
115
 
116
- var xrefStart = Encoding.ASCII.GetByteCount(buffer.ToString());
117
- var xref = new StringBuilder();
118
- xref.Append("xref\n");
119
- xref.Append($"0 {objects.Count + 1}\n");
120
- xref.Append("0000000000 65535 f \n");
121
-
122
- foreach (var offset in offsets.Skip(1))
123
  {
124
- xref.Append($"{offset:D10} 00000 n \n");
125
- }
 
 
 
 
 
126
 
127
- xref.Append("trailer\n");
128
- xref.Append($"<< /Size {objects.Count + 1} /Root 1 0 R >>\n");
129
- xref.Append("startxref\n");
130
- xref.Append($"{xrefStart}\n");
131
- xref.Append("%%EOF");
132
 
133
- buffer.Append(xref);
134
- return Encoding.ASCII.GetBytes(buffer.ToString());
 
 
135
  }
136
 
137
- private static string BuildContentStream(string title, IReadOnlyList<string> lines, int pageNumber, int totalPages)
138
  {
139
- var content = new StringBuilder();
140
- content.AppendLine("BT");
141
- content.AppendLine("/F1 16 Tf");
142
- content.AppendLine($"{LeftMargin} {PageHeight - TopMargin} Td");
143
- content.AppendLine($"({EscapeText(title)}) Tj");
144
- content.AppendLine("/F1 10 Tf");
145
- content.AppendLine($"0 -22 Td");
146
- content.AppendLine($"({EscapeText($"Page {pageNumber} of {totalPages}")}) Tj");
147
 
148
- foreach (var line in lines)
149
  {
150
- content.AppendLine($"0 -{LineSpacing} Td");
151
- content.AppendLine($"({EscapeText(line)}) Tj");
 
 
 
 
 
 
 
 
 
 
152
  }
153
 
154
- content.AppendLine("ET");
155
- return content.ToString();
 
 
 
 
156
  }
157
 
158
- private static IEnumerable<string> WrapLine(string? line)
 
 
 
159
  {
160
- if (string.IsNullOrWhiteSpace(line))
161
  {
162
- yield return string.Empty;
163
- yield break;
164
- }
 
165
 
166
- const int maxWidth = 92;
167
- var remaining = line.Trim();
 
 
 
 
 
 
168
 
169
- while (remaining.Length > maxWidth)
170
  {
171
- var breakIndex = remaining.LastIndexOf(' ', maxWidth);
172
- if (breakIndex <= 0)
173
  {
174
- breakIndex = maxWidth;
175
  }
176
 
177
- yield return remaining[..breakIndex].Trim();
178
- remaining = remaining[breakIndex..].TrimStart();
179
  }
180
 
181
- if (!string.IsNullOrWhiteSpace(remaining))
 
 
 
 
 
 
 
 
182
  {
183
- yield return remaining;
184
  }
 
 
 
 
 
 
 
 
 
 
 
185
  }
186
 
 
 
 
187
  private static string NormalizeCell(string? value)
188
  {
189
  if (string.IsNullOrWhiteSpace(value))
@@ -193,13 +254,4 @@ public static class SimplePdfReportBuilder
193
 
194
  return value.Replace("\r", " ").Replace("\n", " ").Trim();
195
  }
196
-
197
- private static string EscapeText(string value)
198
- {
199
- var ascii = new string(value.Select(ch => ch is >= ' ' and <= '~' ? ch : '?').ToArray());
200
- return ascii
201
- .Replace("\\", "\\\\")
202
- .Replace("(", "\\(")
203
- .Replace(")", "\\)");
204
- }
205
  }
 
1
+ using System.Globalization;
2
+ using SkiaSharp;
3
+ using SkiaSharp.HarfBuzz;
4
 
5
  namespace TaskTrackingSystem.Shared;
6
 
7
  public static class SimplePdfReportBuilder
8
  {
9
+ private const float PageWidth = 612;
10
+ private const float PageHeight = 792;
11
+ private const float LeftMargin = 48;
12
+ private const float TopMargin = 52;
13
+ private const float RightMargin = 48;
14
+ private const float TitleFontSize = 16;
15
+ private const float BodyFontSize = 10;
16
+ private const float LineSpacing = 14;
17
  private const int BodyLinesPerPage = 42;
18
+ private const string FontPathEnvironmentVariable = "TASKTRACKING_PDF_FONT_PATH";
19
+
20
+ private static readonly string[] RequiredGlyphSamples = ["ABC0123|:-", "မြန်မာ"];
21
 
22
  public static byte[] BuildTableReport(
23
  string title,
 
29
 
30
  if (summaryLines != null)
31
  {
32
+ lines.AddRange(summaryLines.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim()));
 
 
 
33
  }
34
 
35
  if (headers.Count > 0)
36
  {
37
  lines.Add(string.Join(" | ", headers));
38
+ lines.Add(new string('-', 72));
39
  }
40
 
41
+ lines.AddRange(rows.Select(row => string.Join(" | ", row.Select(NormalizeCell))));
 
 
 
42
 
43
  if (lines.Count == 0)
44
  {
 
50
 
51
  public static byte[] BuildTextReport(string title, IEnumerable<string> bodyLines)
52
  {
53
+ using var typeface = LoadReportTypeface();
54
+ using var shaper = new SKShaper(typeface);
55
+ using var bodyFont = new SKFont(typeface, BodyFontSize);
56
+
57
+ var availableWidth = PageWidth - LeftMargin - RightMargin;
58
  var normalizedLines = bodyLines
59
+ .SelectMany(line => WrapLine(line, availableWidth, shaper, bodyFont))
60
  .ToList();
61
 
62
  var pages = normalizedLines
 
66
 
67
  if (pages.Count == 0)
68
  {
69
+ pages.Add(["No data available."]);
70
  }
71
 
72
+ using var output = new MemoryStream();
73
+ using var document = SKDocument.CreatePdf(output)
74
+ ?? throw new InvalidOperationException("The PDF renderer could not be initialized.");
 
 
 
75
 
76
+ for (var pageIndex = 0; pageIndex < pages.Count; pageIndex++)
77
  {
78
+ using var canvas = document.BeginPage(PageWidth, PageHeight);
79
+ DrawPage(canvas, shaper, typeface, title, pages[pageIndex], pageIndex + 1, pages.Count);
80
+ document.EndPage();
81
  }
82
 
83
+ document.Close();
84
+ return output.ToArray();
85
+ }
86
 
87
+ private static void DrawPage(
88
+ SKCanvas canvas,
89
+ SKShaper shaper,
90
+ SKTypeface typeface,
91
+ string title,
92
+ IReadOnlyList<string> lines,
93
+ int pageNumber,
94
+ int totalPages)
95
+ {
96
+ canvas.Clear(SKColors.White);
97
+
98
+ using var titleFont = new SKFont(typeface, TitleFontSize);
99
+ using var bodyFont = new SKFont(typeface, BodyFontSize);
100
+ using var paint = new SKPaint
101
  {
102
+ Color = SKColors.Black,
103
+ IsAntialias = true
104
+ };
105
+
106
+ canvas.DrawShapedText(shaper, title, LeftMargin, TopMargin, SKTextAlign.Left, titleFont, paint);
107
+ canvas.DrawShapedText(
108
+ shaper,
109
+ $"Page {pageNumber} of {totalPages}",
110
+ LeftMargin,
111
+ TopMargin + 22,
112
+ SKTextAlign.Left,
113
+ bodyFont,
114
+ paint);
115
+
116
+ var baseline = TopMargin + 22 + LineSpacing;
117
+ foreach (var line in lines)
118
+ {
119
+ baseline += LineSpacing;
120
 
121
+ if (!string.IsNullOrEmpty(line))
122
+ {
123
+ canvas.DrawShapedText(shaper, line, LeftMargin, baseline, SKTextAlign.Left, bodyFont, paint);
124
+ }
125
+ }
126
+ }
127
 
128
+ private static IEnumerable<string> WrapLine(string? line, float maxWidth, SKShaper shaper, SKFont font)
129
+ {
130
+ if (string.IsNullOrWhiteSpace(line))
131
  {
132
+ yield return string.Empty;
133
+ yield break;
134
  }
135
 
136
+ var remaining = line.Trim();
137
+ while (MeasureText(remaining, shaper, font) > maxWidth)
 
 
 
 
 
138
  {
139
+ var breakIndex = FindBreakIndex(remaining, maxWidth, shaper, font);
140
+ var wrappedLine = remaining[..breakIndex].TrimEnd();
141
+
142
+ if (wrappedLine.Length == 0)
143
+ {
144
+ break;
145
+ }
146
 
147
+ yield return wrappedLine;
148
+ remaining = remaining[breakIndex..].TrimStart();
149
+ }
 
 
150
 
151
+ if (remaining.Length > 0)
152
+ {
153
+ yield return remaining;
154
+ }
155
  }
156
 
157
+ private static int FindBreakIndex(string text, float maxWidth, SKShaper shaper, SKFont font)
158
  {
159
+ var textElementIndexes = StringInfo.ParseCombiningCharacters(text);
160
+ var low = 1;
161
+ var high = textElementIndexes.Length;
162
+ var fittingElementCount = 1;
 
 
 
 
163
 
164
+ while (low <= high)
165
  {
166
+ var middle = low + ((high - low) / 2);
167
+ var endIndex = middle < textElementIndexes.Length ? textElementIndexes[middle] : text.Length;
168
+
169
+ if (MeasureText(text[..endIndex], shaper, font) <= maxWidth)
170
+ {
171
+ fittingElementCount = middle;
172
+ low = middle + 1;
173
+ }
174
+ else
175
+ {
176
+ high = middle - 1;
177
+ }
178
  }
179
 
180
+ var fittingEndIndex = fittingElementCount < textElementIndexes.Length
181
+ ? textElementIndexes[fittingElementCount]
182
+ : text.Length;
183
+ var whitespaceIndex = text.LastIndexOf(' ', Math.Max(0, fittingEndIndex - 1), fittingEndIndex);
184
+
185
+ return whitespaceIndex > 0 ? whitespaceIndex : fittingEndIndex;
186
  }
187
 
188
+ private static float MeasureText(string text, SKShaper shaper, SKFont font)
189
+ => string.IsNullOrEmpty(text) ? 0 : shaper.Shape(text, font).Width;
190
+
191
+ private static SKTypeface LoadReportTypeface()
192
  {
193
+ foreach (var path in GetFontCandidates())
194
  {
195
+ if (!File.Exists(path))
196
+ {
197
+ continue;
198
+ }
199
 
200
+ var typeface = SKTypeface.FromFile(path);
201
+ if (typeface != null && SupportsReportGlyphs(typeface))
202
+ {
203
+ return typeface;
204
+ }
205
+
206
+ typeface?.Dispose();
207
+ }
208
 
209
+ foreach (var family in new[] { "Noto Sans Myanmar", "Myanmar Text", "Nirmala UI", "Padauk" })
210
  {
211
+ var typeface = SKTypeface.FromFamilyName(family);
212
+ if (typeface != null && SupportsReportGlyphs(typeface))
213
  {
214
+ return typeface;
215
  }
216
 
217
+ typeface?.Dispose();
 
218
  }
219
 
220
+ throw new InvalidOperationException(
221
+ "No Unicode font capable of rendering Burmese was found. " +
222
+ $"Install Noto Sans Myanmar or set {FontPathEnvironmentVariable} to a compatible .ttf file.");
223
+ }
224
+
225
+ private static IEnumerable<string> GetFontCandidates()
226
+ {
227
+ var configuredPath = Environment.GetEnvironmentVariable(FontPathEnvironmentVariable);
228
+ if (!string.IsNullOrWhiteSpace(configuredPath))
229
  {
230
+ yield return configuredPath;
231
  }
232
+
233
+ var windowsDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
234
+ if (!string.IsNullOrWhiteSpace(windowsDirectory))
235
+ {
236
+ yield return Path.Combine(windowsDirectory, "Fonts", "mmrtext.ttf");
237
+ yield return Path.Combine(windowsDirectory, "Fonts", "Nirmala.ttf");
238
+ }
239
+
240
+ yield return "/usr/share/fonts/truetype/noto/NotoSansMyanmar-Regular.ttf";
241
+ yield return "/usr/share/fonts/truetype/padauk/Padauk-Regular.ttf";
242
+ yield return "/usr/share/fonts/opentype/noto/NotoSansMyanmar-Regular.ttf";
243
  }
244
 
245
+ private static bool SupportsReportGlyphs(SKTypeface typeface)
246
+ => RequiredGlyphSamples.All(sample => typeface.GetGlyphs(sample).All(glyph => glyph != 0));
247
+
248
  private static string NormalizeCell(string? value)
249
  {
250
  if (string.IsNullOrWhiteSpace(value))
 
254
 
255
  return value.Replace("\r", " ").Replace("\n", " ").Trim();
256
  }
 
 
 
 
 
 
 
 
 
257
  }
TaskTrackingSystem.Shared/TaskTrackingSystem.Shared.csproj CHANGED
@@ -18,4 +18,10 @@
18
  <Folder Include="Models\Role\" />
19
  </ItemGroup>
20
 
 
 
 
 
 
 
21
  </Project>
 
18
  <Folder Include="Models\Role\" />
19
  </ItemGroup>
20
 
21
+ <ItemGroup>
22
+ <PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
23
+ <PackageReference Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
24
+ <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.4" />
25
+ </ItemGroup>
26
+
27
  </Project>
Tests/TaskTrackingSystem.Tests/SimplePdfReportBuilderTests.cs ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System.Text;
2
+ using TaskTrackingSystem.Shared;
3
+ using Xunit;
4
+
5
+ namespace TaskTrackingSystem.Tests;
6
+
7
+ public sealed class SimplePdfReportBuilderTests
8
+ {
9
+ [Fact]
10
+ public void BuildTableReport_GeneratesPdfForBurmeseText()
11
+ {
12
+ var pdf = SimplePdfReportBuilder.BuildTableReport(
13
+ "လုပ်ငန်း အစီရင်ခံစာ",
14
+ ["ရှာဖွေရန်: လုပ်ငန်းအားလုံး"],
15
+ ["လုပ်ငန်း", "စီမံကိန်း", "အခြေအနေ"],
16
+ [["မြန်မာစာ စမ်းသပ်လုပ်ငန်း", "စီမံကိန်းတစ်", "လုပ်ဆောင်ဆဲ"]]);
17
+
18
+ Assert.True(pdf.Length > 1_000);
19
+ Assert.Equal("%PDF", Encoding.ASCII.GetString(pdf, 0, 4));
20
+ }
21
+ }