KiCad PCB EDA Suite
Loading...
Searching...
No Matches
test_pdf_unicode_plot.cpp
Go to the documentation of this file.
1/*
2 * This program source code file is part of KiCad, a free EDA CAD application.
3 *
4 * Copyright The KiCad Developers, see AUTHORS.TXT for contributors.
5 *
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU General Public License
8 * as published by the Free Software Foundation; either version 2
9 * of the License, or (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20#include <boost/test/unit_test.hpp>
21
22#include <wx/filename.h>
23#include <wx/filefn.h>
24#include <wx/ffile.h>
25#include <wx/utils.h>
26#include <wx/image.h>
27#include <zlib.h>
28
30#include <advanced_config.h>
31#include <render_settings.h>
32#include <trigo.h>
33#include <font/font.h>
34#include <font/stroke_font.h>
37
38/* Test objective:
39 * Ensure PDF_PLOTTER can emit glyphs for ASCII plus some Cyrillic, Japanese and Chinese
40 * characters using stroke font fallback. We verify by checking resulting PDF file contains
41 * expected glyph names or UTF-16 hex sequences for those code points.
42 */
43
44BOOST_AUTO_TEST_SUITE( PDFUnicodePlot )
45
46static wxString getTempPdfPath( const wxString& name ) { return MakeTempPdfPath( name ); }
47
48// Comprehensive mapping test: emit all four style variants in a single PDF and verify that
49// every style's ToUnicode CMap contains expected codepoints (Cyrillic 041F, Japanese 65E5, Chinese 672C).
50BOOST_AUTO_TEST_CASE( PlotMultilingualAllStylesMappings )
51{
52 const std::string sampleUtf8 = "ABCDEF Привет 日本語 漢字";
53 wxString sample = wxString::FromUTF8( sampleUtf8.c_str() );
54 wxString pdfPath = getTempPdfPath( "kicad_pdf_unicode_allstyles" );
55
56 PDF_PLOTTER plotter;
57 SIMPLE_RENDER_SETTINGS renderSettings;
58
59 plotter.SetRenderSettings( &renderSettings );
60 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
61 plotter.SetViewport( VECTOR2I(0,0), 1.0, 1.0, false );
62 BOOST_REQUIRE( plotter.StartPlot( wxT("1"), wxT("TestPage") ) );
63
64 auto emitStyle = [&]( bool bold, bool italic, int yoff )
65 {
66 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 3000, 300, bold, italic );
67 auto strokeFont = LoadStrokeFontUnique();
68 KIFONT::METRICS metrics;
69
70 plotter.PlotText( VECTOR2I( 50000, 60000 - yoff ), COLOR4D( 0, 0, 0, 1 ), sample, attrs,
71 strokeFont.get(), metrics );
72 };
73
74 emitStyle( false, false, 0 ); // normal
75 emitStyle( true, false, 8000 ); // bold
76 emitStyle( false, true, 16000 ); // italic
77 emitStyle( true, true, 24000 ); // bold-italic
78
79 plotter.EndPlot();
80
81 // Read entire PDF (may have compression). We'll search each Type3 font object's preceding
82 // name to separate CMaps logically.
83 std::string buffer; BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer ) );
84 BOOST_CHECK( buffer.rfind( "%PDF", 0 ) == 0 );
85
86 // Count how many distinct KiCadStrokeCMap names present; expect at least 4 (one per style).
87 int cmapCount = CountOccurrences( buffer, "/CMapName /KiCadStrokeCMap" );
88
89 BOOST_CHECK_MESSAGE( cmapCount >= 4, "Expected at least 4 CMaps (got " << cmapCount << ")" );
90
91 auto requireAll = [&]( const char* codeHex, const char* label ) {
92 int occurrences = CountOccurrences( buffer, codeHex );
93 BOOST_CHECK_MESSAGE( occurrences >= 4, "Codepoint " << label << " (" << codeHex
94 << ") expected in all 4 styles; found " << occurrences );
95 };
96
97 requireAll( "041F", "Cyrillic PE" );
98 requireAll( "65E5", "Kanji 日" );
99 requireAll( "672C", "Kanji 本" );
100
101 MaybeRemoveFile( pdfPath );
102}
103
104BOOST_AUTO_TEST_CASE( PlotMultilingualText )
105{
106 // UTF-8 sample with Latin, Cyrillic, Japanese, Chinese (shared Han) characters.
107 const std::string sampleUtf8 = "ABCDEF Привет 日本語 漢字";
108 wxString sample = wxString::FromUTF8( sampleUtf8.c_str() );
109
110 wxString pdfPath = getTempPdfPath( "kicad_pdf_unicode" );
111
112 PDF_PLOTTER plotter;
113
114 // Force uncompressed PDF streams via environment so we can directly search for
115 // unicode hex strings in the output (otherwise they are Flate compressed).
116 // Do not force debug writer; allow normal compression so page content is valid.
117 // The plotter expects non-null render settings for default pen width and font queries.
118 // Provide a minimal concrete RENDER_SETTINGS implementation for the plotter.
119 SIMPLE_RENDER_SETTINGS renderSettings; plotter.SetRenderSettings( &renderSettings );
120
121 // Minimal viewport and plot setup. Use 1 IU per decimil so internal coordinates are small
122 // and resulting translation keeps text inside the page for rasterization.
123 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
124 plotter.SetViewport( VECTOR2I(0,0), 1.0, 1.0, false );
125 // StartPlot opens first page stream internally; use simple page number
126 BOOST_REQUIRE( plotter.StartPlot( wxT("1"), wxT("TestPage") ) );
127
128 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 3000, 300, false, false );
129 auto strokeFont = LoadStrokeFontUnique();
130 KIFONT::METRICS metrics; // not used for stroke fallback
131
132 // Plot near lower-left inside the page.
133 // Place text near the top of the page in internal units so after the 0.0072 scale it
134 // appears well within the MediaBox. Empirically m_paperSize.y ~ 116k internal units.
135 plotter.PlotText( VECTOR2I( 50000, 60000 ), COLOR4D( 0.0, 0.0, 0.0, 1.0 ), sample, attrs,
136 strokeFont.get(), metrics );
137
138 plotter.EndPlot();
139
140 // Read file back and check for expected UTF-16 hex encodings for some code points
141 // We expect CMap to contain mappings. E.g. '041F' (Cyrillic capital Pe), '65E5'(日), '672C'(本).
142 std::string buffer2; BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer2 ) );
143 auto contains = [&]( const char* needle ) { return PdfContains( buffer2, needle ); };
144
145 BOOST_CHECK_MESSAGE( contains( "041F" ), "Missing Cyrillic glyph mapping (041F)" );
146 BOOST_CHECK_MESSAGE( contains( "0420" ) || contains( "0440" ), "Missing Cyrillic glyph mapping (0420/0440)" );
147 BOOST_CHECK_MESSAGE( contains( "65E5" ), "Missing Japanese Kanji glyph mapping (65E5)" );
148 BOOST_CHECK_MESSAGE( contains( "672C" ), "Missing Japanese Kanji glyph mapping (672C)" );
149 BOOST_CHECK_MESSAGE( contains( "6F22" ) || contains( "6漢" ), "Expect Chinese Han character mapping (6F22 / 漢)" );
150
151 // Cleanup temp file (unless debugging requested)
152 // Optional: rasterize PDF to image (requires poppler 'pdftoppm').
153 // We treat absence of the tool as a skipped sub-check rather than a failure.
154 {
155 long darkPixels = 0;
156 if( RasterizePdfCountDark( pdfPath, 72, 240, darkPixels ) )
157 {
158 BOOST_CHECK_MESSAGE( darkPixels > 200,
159 "Rasterized PDF appears blank or too sparse (" << darkPixels
160 << " dark pixels)" );
161 }
162 else
163 {
164 BOOST_TEST_MESSAGE( "pdftoppm not available or failed; skipping raster validation" );
165 }
166 }
167
168 MaybeRemoveFile( pdfPath );
169}
170
171BOOST_AUTO_TEST_CASE( PlotMultilingualTextBold )
172{
173 const std::string sampleUtf8 = "ABCDEF Привет 日本語 漢字";
174 wxString sample = wxString::FromUTF8( sampleUtf8.c_str() );
175
176 wxString pdfPath = getTempPdfPath( "kicad_pdf_unicode_bold" );
177
178 PDF_PLOTTER plotter;
179 SIMPLE_RENDER_SETTINGS renderSettings;
180 plotter.SetRenderSettings( &renderSettings );
181 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
182 plotter.SetViewport( VECTOR2I(0,0), 1.0, 1.0, false );
183 BOOST_REQUIRE( plotter.StartPlot( wxT("1"), wxT("TestPage") ) );
184
185 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 3000, 300, true, false );
186 auto strokeFont = LoadStrokeFontUnique();
187 KIFONT::METRICS metrics;
188 plotter.PlotText( VECTOR2I( 50000, 60000 ), COLOR4D( 0.0, 0.0, 0.0, 1.0 ), sample, attrs,
189 strokeFont.get(), metrics );
190 plotter.EndPlot();
191 std::string buffer3; BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer3 ) );
192 auto contains = [&]( const char* needle ) { return PdfContains( buffer3, needle ); };
193 BOOST_CHECK_MESSAGE( contains( "041F" ), "Missing Cyrillic glyph mapping (bold 041F)" );
194 BOOST_CHECK_MESSAGE( contains( "65E5" ), "Missing Japanese glyph mapping (bold 65E5)" );
195
196 MaybeRemoveFile( pdfPath );
197}
198
199BOOST_AUTO_TEST_CASE( PlotMultilingualTextItalic )
200{
201 const std::string sampleUtf8 = "ABCDEF Привет 日本語 漢字";
202 wxString sample = wxString::FromUTF8( sampleUtf8.c_str() );
203 wxString pdfPath = getTempPdfPath( "kicad_pdf_unicode_italic" );
204 PDF_PLOTTER plotter;
205 SIMPLE_RENDER_SETTINGS renderSettings;
206
207 plotter.SetRenderSettings( &renderSettings );
208 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
209 plotter.SetViewport( VECTOR2I(0,0), 1.0, 1.0, false );
210 BOOST_REQUIRE( plotter.StartPlot( wxT( "1" ), wxT( "TestPage" ) ) );
211 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 3000, 300, false, true );
212 auto strokeFont = LoadStrokeFontUnique();
213 KIFONT::METRICS metrics;
214 plotter.PlotText( VECTOR2I( 50000, 60000 ), COLOR4D( 0, 0, 0, 1 ), sample, attrs, strokeFont.get(), metrics );
215 plotter.EndPlot();
216
217 std::string buffer4; BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer4 ) );
218 auto contains = [&]( const char* n ) { return PdfContains( buffer4, n ); };
219 BOOST_CHECK_MESSAGE( contains( "041F" ), "Missing Cyrillic glyph mapping (italic 041F)" );
220 BOOST_CHECK_MESSAGE( contains( "65E5" ), "Missing Japanese glyph mapping (italic 65E5)" );
221 MaybeRemoveFile( pdfPath );
222}
223
224BOOST_AUTO_TEST_CASE( PlotMultilingualTextBoldItalic )
225{
226 const std::string sampleUtf8 = "ABCDEF Привет 日本語 漢字";
227 wxString sample = wxString::FromUTF8( sampleUtf8.c_str() );
228 wxString pdfPath = getTempPdfPath( "kicad_pdf_unicode_bolditalic" );
229 PDF_PLOTTER plotter;
230 SIMPLE_RENDER_SETTINGS renderSettings;
231
232 plotter.SetRenderSettings( &renderSettings );
233 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
234
235 plotter.SetViewport( VECTOR2I( 0, 0 ), 1.0, 1.0, false );
236 BOOST_REQUIRE( plotter.StartPlot( wxT( "1" ), wxT( "TestPage" ) ) );
237
238 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 3000, 300, true, true );
239 auto strokeFont = LoadStrokeFontUnique();
240 KIFONT::METRICS metrics;
241
242 plotter.PlotText( VECTOR2I( 50000, 60000 ), COLOR4D( 0, 0, 0, 1 ), sample, attrs, strokeFont.get(), metrics );
243 plotter.EndPlot();
244
245 std::string buffer5; BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer5 ) );
246 auto contains = [&]( const char* n ) { return PdfContains( buffer5, n ); };
247 BOOST_CHECK_MESSAGE( contains( "041F" ), "Missing Cyrillic glyph mapping (bold-italic 041F)" );
248 BOOST_CHECK_MESSAGE( contains( "65E5" ), "Missing Japanese glyph mapping (bold-italic 65E5)" );
249 MaybeRemoveFile( pdfPath );
250}
251
252// Verify that d1 bounding boxes account for X offset and stroke width so PDF viewers don't
253// clip the rendered glyphs. Regression test for gitlab.com/kicad/code/kicad/-/issues/23621.
254BOOST_AUTO_TEST_CASE( GlyphBBoxIncludesOffsetAndStrokeWidth )
255{
256 const std::string sampleUtf8 = "MW";
257 wxString sample = wxString::FromUTF8( sampleUtf8.c_str() );
258 wxString pdfPath = getTempPdfPath( "kicad_pdf_bbox_check" );
259
260 PDF_PLOTTER plotter;
261 SIMPLE_RENDER_SETTINGS renderSettings;
262
263 plotter.SetRenderSettings( &renderSettings );
264 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
265 plotter.SetViewport( VECTOR2I( 0, 0 ), 1.0, 1.0, false );
266 BOOST_REQUIRE( plotter.StartPlot( wxT( "1" ), wxT( "BBoxTest" ) ) );
267
268 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 3000, 300, false, false );
269 auto strokeFont = LoadStrokeFontUnique();
270 KIFONT::METRICS metrics;
271
272 plotter.PlotText( VECTOR2I( 50000, 60000 ), COLOR4D( 0, 0, 0, 1 ), sample, attrs,
273 strokeFont.get(), metrics );
274 plotter.EndPlot();
275
276 std::string buffer;
277 BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer ) );
278
279 // Parse d1 operators: format is "width 0 minX minY maxX maxY d1"
280 // Verify that no glyph has a minX of exactly 0 (would mean X offset was not applied) and
281 // that the bbox extends beyond the stroke center coordinates by at least half the stroke
282 // width.
283 const ADVANCED_CFG& cfg = ADVANCED_CFG::GetCfg();
284 double unitsPerEm = 1000.0;
285 double expectedXOffset = cfg.m_PDFStrokeFontXOffset * unitsPerEm;
286 double expectedHalfStroke = unitsPerEm * cfg.m_PDFStrokeFontWidthFactor / 2.0;
287
288 // Find all d1 operators (skip .notdef which has all-zero bbox)
289 std::string::size_type pos = 0;
290 int checkedGlyphs = 0;
291
292 while( ( pos = buffer.find( " d1 ", pos ) ) != std::string::npos )
293 {
294 // Walk backwards to find the start of the d1 line
295 std::string::size_type lineStart = buffer.rfind( '\n', pos );
296
297 if( lineStart == std::string::npos )
298 lineStart = 0;
299 else
300 lineStart++;
301
302 std::string d1Line = buffer.substr( lineStart, pos + 3 - lineStart );
303
304 double width, wy, minX, minY, maxX, maxY;
305
306 if( sscanf( d1Line.c_str(), "%lf %lf %lf %lf %lf %lf", &width, &wy, &minX, &minY,
307 &maxX, &maxY ) == 6 )
308 {
309 // Skip .notdef (all zeros)
310 if( width == 0.0 && maxX == 0.0 )
311 {
312 pos += 4;
313 continue;
314 }
315
316 // The bbox minX should be shifted by the X offset minus half stroke width.
317 // With default offset 0.1 and stroke factor 0.12, minX should be around
318 // 0.1*1000 - 0.12*1000/2 = 100 - 60 = 40, not 0.
319 BOOST_CHECK_MESSAGE( minX < -expectedHalfStroke + 1.0 || minX > 0.1,
320 "Glyph bbox minX (" << minX
321 << ") suggests X offset or stroke padding is missing" );
322
323 // maxX must exceed the advance width to account for offset + stroke padding
324 BOOST_CHECK_MESSAGE( maxX > width,
325 "Glyph bbox maxX (" << maxX << ") must exceed advance width ("
326 << width << ") to prevent clipping" );
327
328 checkedGlyphs++;
329 }
330
331 pos += 4;
332 }
333
334 BOOST_CHECK_MESSAGE( checkedGlyphs >= 2,
335 "Expected at least 2 non-notdef glyphs, found " << checkedGlyphs );
336
337 MaybeRemoveFile( pdfPath );
338}
339
340// Test Y offset bounding box fix: ensure characters are not clipped when Y offset is applied
341BOOST_AUTO_TEST_CASE( PlotMultilingualTextWithYOffset )
342{
343 // Temporarily modify the Y offset configuration
344 ADVANCED_CFG& cfg = const_cast<ADVANCED_CFG&>( ADVANCED_CFG::GetCfg() );
345 double originalOffset = cfg.m_PDFStrokeFontYOffset;
346 cfg.m_PDFStrokeFontYOffset = 0.2; // 20% of EM unit offset upward
347
348 const std::string sampleUtf8 = "Yg Test ñ"; // characters with ascenders and descenders
349 wxString sample = wxString::FromUTF8( sampleUtf8.c_str() );
350 wxString pdfPath = getTempPdfPath( "kicad_pdf_unicode_yoffset" );
351
352 PDF_PLOTTER plotter;
353 SIMPLE_RENDER_SETTINGS renderSettings;
354
355 plotter.SetRenderSettings( &renderSettings );
356 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
357 plotter.SetViewport( VECTOR2I( 0, 0 ), 1.0, 1.0, false );
358 BOOST_REQUIRE( plotter.StartPlot( wxT( "1" ), wxT( "TestPage" ) ) );
359
360 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 4000, 400, false, false );
361 auto strokeFont = LoadStrokeFontUnique();
362 KIFONT::METRICS metrics;
363 plotter.PlotText( VECTOR2I( 50000, 60000 ), COLOR4D( 0, 0, 0, 1 ), sample, attrs, strokeFont.get(), metrics );
364 plotter.EndPlot();
365
366
367 // Restore original Y offset
368 cfg.m_PDFStrokeFontYOffset = originalOffset;
369
370 // Basic PDF validation and decompression
371 std::string buffer6; BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer6 ) );
372 BOOST_CHECK( buffer6.rfind( "%PDF", 0 ) == 0 );
373
374 // Check that bounding boxes exist and are reasonable (not clipped)
375 // Look for d1 operators which specify character bounding boxes
376 BOOST_CHECK_MESSAGE( buffer6.find( "d1" ) != std::string::npos,
377 "PDF should contain d1 operators for glyph bounding boxes" );
378
379 MaybeRemoveFile( pdfPath );
380}
381
382BOOST_AUTO_TEST_CASE( PlotOutlineFontEmbedding )
383{
384 wxString pdfPath = getTempPdfPath( "kicad_pdf_outline_font" );
385
386 // Locate test font file (Noto Sans) in test resources
387 wxFileName fontFile( KI_TEST::GetTestDataRootDir() );
388 fontFile.RemoveLastDir();
389 fontFile.AppendDir( wxT( "resources" ) );
390 fontFile.AppendDir( wxT( "fonts" ) );
391 fontFile.SetFullName( wxT( "NotoSans-Regular.ttf" ) );
392 wxString fontPath = fontFile.GetFullPath();
393
394 BOOST_REQUIRE( wxFileExists( fontPath ) );
395
396 PDF_PLOTTER plotter;
397 SIMPLE_RENDER_SETTINGS renderSettings;
398
399 plotter.SetRenderSettings( &renderSettings );
400 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
401 plotter.SetViewport( VECTOR2I(0,0), 1.0, 1.0, false );
402 BOOST_REQUIRE( plotter.StartPlot( wxT("1"), wxT("OutlineFont") ) );
403
404 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 4000, 0, false, false );
405
406 std::vector<wxString> embeddedFonts;
407 embeddedFonts.push_back( fontPath );
408
409 KIFONT::FONT* outlineFont = KIFONT::FONT::GetFont( wxT( "Noto Sans" ), false, false, &embeddedFonts );
410 KIFONT::METRICS metrics;
411
412 wxString sample = wxString::FromUTF8( "Outline café" );
413
414 plotter.PlotText( VECTOR2I( 42000, 52000 ), COLOR4D( 0, 0, 0, 1 ), sample, attrs,
415 outlineFont, metrics );
416
417 plotter.EndPlot();
418
419 wxFFile file( pdfPath, "rb" );
420 BOOST_REQUIRE( file.IsOpened() );
421 wxFileOffset len = file.Length();
422 std::string buffer; buffer.resize( (size_t) len ); file.Read( buffer.data(), len );
423 BOOST_CHECK( buffer.rfind( "%PDF", 0 ) == 0 );
424
425 auto appendDecompressed = [&]() { std::string tmp; ReadPdfWithDecompressedStreams( pdfPath, tmp ); buffer.swap( tmp ); };
426 appendDecompressed();
427
428 BOOST_CHECK_MESSAGE( buffer.find( "/CIDFontType2" ) != std::string::npos,
429 "Expected CIDFontType2 descendant font" );
430 BOOST_CHECK_MESSAGE( buffer.find( "/FontFile2" ) != std::string::npos,
431 "Embedded outline font should include FontFile2 stream" );
432 BOOST_CHECK_MESSAGE( buffer.find( "AAAAAA+Noto-Sans" ) != std::string::npos,
433 "BaseFont should reference Noto Sans subset" );
434 BOOST_CHECK_MESSAGE( buffer.find( "00E9" ) != std::string::npos,
435 "ToUnicode map should include Latin character with accent" );
436 BOOST_CHECK_MESSAGE( buffer.find( "/KiCadOutline" ) != std::string::npos,
437 "Outline font resource entry missing" );
438
439 // Optional: rasterize PDF to image (requires poppler 'pdftoppm').
440 // We treat absence of the tool as a skipped sub-check rather than a failure.
441 {
442 wxString rasterBase = wxFileName::CreateTempFileName( wxT("kicad_pdf_raster") );
443 wxString cmd = wxString::Format( wxT("pdftoppm -r 72 -singlefile -png \"%s\" \"%s\""),
444 pdfPath, rasterBase );
445
446 int ret = wxExecute( cmd, wxEXEC_SYNC );
447
448 if( ret == 0 )
449 {
450 wxString pngPath = rasterBase + wxT(".png");
451
452 if( wxFileExists( pngPath ) )
453 {
454 // Ensure PNG handler is available
455 if( !wxImage::FindHandler( wxBITMAP_TYPE_PNG ) )
456 wxImage::AddHandler( new wxPNGHandler );
457
458 wxImage img( pngPath );
459 BOOST_REQUIRE_MESSAGE( img.IsOk(), "Failed to load rasterized PDF image" );
460
461 long darkPixels = 0;
462 int w = img.GetWidth();
463 int h = img.GetHeight();
464
465 for( int y = 0; y < h; ++y )
466 {
467 for( int x = 0; x < w; ++x )
468 {
469 unsigned char r = img.GetRed( x, y );
470 unsigned char g = img.GetGreen( x, y );
471 unsigned char b = img.GetBlue( x, y );
472
473 // Count any non-near-white pixel as drawn content
474 if( r < 240 || g < 240 || b < 240 )
475 ++darkPixels;
476 }
477 }
478
479 // Demand at least 100 non-white pixels to consider outline font rendered correctly.
480 // This threshold is lower than stroke font since outline fonts may render differently.
481 BOOST_CHECK_MESSAGE( darkPixels > 100,
482 "Rasterized PDF appears blank or too sparse (" << darkPixels
483 << " dark pixels). Outline font may not be rendering correctly." );
484
485 // Housekeeping
486 wxRemoveFile( pngPath );
487 }
488 else
489 {
490 BOOST_TEST_MESSAGE( "pdftoppm succeeded but PNG output missing; skipping raster validation" );
491 }
492 }
493 else
494 {
495 BOOST_TEST_MESSAGE( "pdftoppm not available or failed; skipping raster validation" );
496 }
497 }
498
499 MaybeRemoveFile( pdfPath );
500}
501
502// Test tab handling in PDF text output (issue #22606).
503// When text contains tab characters, each tab should advance to the next tab stop.
504// We verify that text with tabs produces different glyph positions than without tabs.
505BOOST_AUTO_TEST_CASE( PlotTextWithTabs )
506{
507 wxString pdfPath = getTempPdfPath( "kicad_pdf_tabs" );
508
509 PDF_PLOTTER plotter;
510 SIMPLE_RENDER_SETTINGS renderSettings;
511
512 plotter.SetRenderSettings( &renderSettings );
513 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
514 plotter.SetViewport( VECTOR2I( 0, 0 ), 1.0, 1.0, false );
515 BOOST_REQUIRE( plotter.StartPlot( wxT( "1" ), wxT( "TabTest" ) ) );
516
517 TEXT_ATTRIBUTES attrs = BuildTextAttributes( 3000, 300, false, false );
518 auto strokeFont = LoadStrokeFontUnique();
519 KIFONT::METRICS metrics;
520
521 wxString textWithTab = wxT( "Before\tAfter" );
522 wxString textWithoutTab = wxT( "BeforeAfter" );
523
524 plotter.PlotText( VECTOR2I( 50000, 60000 ), COLOR4D( 0, 0, 0, 1 ), textWithTab, attrs,
525 strokeFont.get(), metrics );
526 plotter.PlotText( VECTOR2I( 50000, 50000 ), COLOR4D( 0, 0, 0, 1 ), textWithoutTab, attrs,
527 strokeFont.get(), metrics );
528
529 plotter.EndPlot();
530
531 std::string buffer;
532 BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer ) );
533 BOOST_CHECK( buffer.rfind( "%PDF", 0 ) == 0 );
534
535 // The PDF should contain text content. Tabs should not produce visible tab glyphs but should
536 // create proper spacing. We verify the PDF is valid and contains our text characters.
537 BOOST_CHECK_MESSAGE( PdfContains( buffer, "0041" ) || PdfContains( buffer, "A" ),
538 "PDF should contain 'A' from 'After'" );
539
540 MaybeRemoveFile( pdfPath );
541}
542
543// Regression test for GitLab issue 23740: stroke-font Type3 glyphs in the PDF output were
544// plotted above and to the left of where they should have been. The old code derived the
545// vertical anchor from StringBoundaryLimits (which inflates by 3*thickness) and forgot to
546// cancel the m_PDFStrokeFontYOffset baked into every Type3 glyph, so character placement
547// varied with the caller's pen width and with the stroke-font Y offset.
548//
549// The test plots the same character with V_TOP, V_CENTER and V_BOTTOM alignments at the same
550// anchor, parses the text-matrix ctm_f value out of the content stream, then verifies that
551// ctm_f corresponds to the positions that FONT::getLinePositions would produce for GAL,
552// translated to PDF device coordinates (with the stroke-font yOffset applied so glyph ink
553// lands where screen rendering would).
554BOOST_AUTO_TEST_CASE( StrokeFontVerticalAlignmentMatchesScreen )
555{
556 wxString pdfPath = getTempPdfPath( "kicad_pdf_valign" );
557
558 PDF_PLOTTER plotter;
559 SIMPLE_RENDER_SETTINGS renderSettings;
560 plotter.SetRenderSettings( &renderSettings );
561 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
562 plotter.SetViewport( VECTOR2I( 0, 0 ), 1.0, 1.0, false );
563 BOOST_REQUIRE( plotter.StartPlot( wxT( "1" ), wxT( "VAlignTest" ) ) );
564
565 const int anchorY = 60000;
566 const int sizeIU = 3000;
567 const int strokeW = 300;
568
569 auto plotAt = [&]( GR_TEXT_V_ALIGN_T aVJustify, bool aItalic )
570 {
571 TEXT_ATTRIBUTES attrs = BuildTextAttributes( sizeIU, strokeW, false, aItalic );
572 attrs.m_Valign = aVJustify;
573 auto strokeFont = LoadStrokeFontUnique();
574 KIFONT::METRICS metrics;
575 plotter.PlotText( VECTOR2I( 50000, anchorY ), COLOR4D( 0, 0, 0, 1 ), wxT( "T" ), attrs,
576 strokeFont.get(), metrics );
577 };
578
579 plotAt( GR_TEXT_V_ALIGN_TOP, false );
580 plotAt( GR_TEXT_V_ALIGN_CENTER, false );
581 plotAt( GR_TEXT_V_ALIGN_BOTTOM, false );
582 plotAt( GR_TEXT_V_ALIGN_TOP, true );
583 plotAt( GR_TEXT_V_ALIGN_CENTER, true );
584 plotter.EndPlot();
585
586 std::string buffer;
587 BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer ) );
588
589 // Capture the full text matrix for every `q ... cm BT ... Tj ET Q` block.
590 struct MATRIX_SAMPLE { double a, b, c, d, e, f; };
591 std::vector<MATRIX_SAMPLE> matrices;
592 std::string::size_type pos = 0;
593
594 while( ( pos = buffer.find( "cm BT", pos ) ) != std::string::npos )
595 {
596 std::string::size_type lineStart = buffer.rfind( '\n', pos );
597 lineStart = ( lineStart == std::string::npos ) ? 0 : lineStart + 1;
598
599 std::string cmLine = buffer.substr( lineStart, pos - lineStart );
600 MATRIX_SAMPLE m{};
601
602 if( sscanf( cmLine.c_str(), "q %lf %lf %lf %lf %lf %lf", &m.a, &m.b, &m.c, &m.d, &m.e,
603 &m.f ) == 6 )
604 {
605 matrices.push_back( m );
606 }
607
608 pos += 5;
609 }
610
611 BOOST_REQUIRE_EQUAL( matrices.size(), 5u );
612
613 const double fontSize = (double) sizeIU;
614 const double thickness = (double) strokeW;
615
616 // GAL cursor offsets from FONT::getLinePositions (single line, height = 1.17 * size):
617 // V_TOP = size (+ size.y below anchor)
618 // V_CENTER = 0.415 * size (size - height/2)
619 // V_BOTTOM = -0.17 * size (size - height)
620 // The PDF stroke path applies a constant yOffset on top of these, which drops out of the
621 // deltas between alignments, so we check those deltas directly. PDF device Y decreases
622 // as IU Y increases, so ctm_f_TOP < ctm_f_CENTER < ctm_f_BOTTOM.
623 const double expected_delta_top_center = ( 1.000 - 0.415 ) * fontSize;
624 const double expected_delta_center_bot = ( 0.415 - ( -0.17 ) ) * fontSize;
625
626 const double observed_delta_top_center = matrices[1].f - matrices[0].f;
627 const double observed_delta_center_bot = matrices[2].f - matrices[1].f;
628
629 BOOST_CHECK_CLOSE( observed_delta_top_center, expected_delta_top_center, 1.0 );
630 BOOST_CHECK_CLOSE( observed_delta_center_bot, expected_delta_center_bot, 1.0 );
631
632 BOOST_CHECK_MESSAGE( matrices[0].f < matrices[1].f && matrices[1].f < matrices[2].f,
633 "Expected ctm_f to decrease from V_BOTTOM to V_CENTER to V_TOP, got "
634 << matrices[0].f << ", " << matrices[1].f << ", " << matrices[2].f );
635
636 // The uncorrected formula (pre-fix) would give 0.5 * (size + 3*thickness) for the V_TOP
637 // to V_CENTER delta. Ensure the observed delta does not match that, which would mean
638 // the alignment fix is inactive.
639 const double uncorrectedDelta = 0.5 * ( fontSize + 3.0 * thickness );
640 BOOST_CHECK_MESSAGE( std::abs( observed_delta_top_center - uncorrectedDelta ) > 1.0,
641 "ctm_f delta for V_TOP vs V_CENTER matches the uncorrected formula; "
642 "the stroke-font alignment fix does not appear to be in effect." );
643
644 // For italic text the Y axis of the text matrix is sheared (adj_c != 0 for horizontal
645 // italic), so the baseline correction has to project onto (adj_c, adj_d) rather than
646 // sin/cos of aOrient. With the buggy sin/cos formula, italic_delta_e between V_TOP and
647 // V_CENTER at angle 0 would be zero (same as the non-italic case); with the fix the
648 // shear transfers part of the correction into the X direction.
649 const double italic_delta_e = matrices[4].e - matrices[3].e;
650 const double nonitalic_delta_e = matrices[1].e - matrices[0].e;
651
652 BOOST_CHECK_MESSAGE( std::abs( matrices[3].c ) > 0.01,
653 "Italic text matrix does not show the expected shear (adj_c == "
654 << matrices[3].c << ")" );
655
656 // Non-italic, horizontal text has adj_c == 0, so delta_e between V-alignments is 0.
657 BOOST_CHECK_SMALL( nonitalic_delta_e, 1.0 );
658
659 // Italic text must show a non-zero delta_e from the shear projection. The direction
660 // is determined by the sign of the shear: with tilt = -ITALIC_TILT and downward IU Y
661 // correction, italic_delta_e has the opposite sign of adj_c (i.e., negative when the
662 // italic shear leans right).
663 BOOST_CHECK_MESSAGE( std::abs( italic_delta_e ) > 1.0,
664 "Italic baseline correction did not shear along the text-matrix Y "
665 "axis; italic_delta_e = "
666 << italic_delta_e
667 << " (should be non-zero when adj_c is non-zero)" );
668 BOOST_CHECK_MESSAGE( italic_delta_e * matrices[3].c < 0,
669 "italic_delta_e should have the opposite sign of adj_c; got "
670 << italic_delta_e << " vs adj_c=" << matrices[3].c );
671
672 MaybeRemoveFile( pdfPath );
673}
674
675
676// Regression test for https://gitlab.com/kicad/code/kicad/-/issues/24419
677// PDF_PLOTTER::renderWord used to advance the per-word cursor by
678// FONT::StringBoundaryLimits, which for stroke fonts inflates the returned
679// bounding box by 3*thickness. The Tj operator that actually renders the word
680// advances by the sum of glyph widths (no inflation), so successive words were
681// pushed too far apart in the rendered PDF. The bug was visible on silkscreen
682// text whose width exceeded the board edge in the exported PDF but fit on the
683// PCB editor canvas.
684//
685// The fix is to derive the cursor advance from FONT::GetTextAsGlyphs, which
686// matches exactly what Tj produces. This test plots a multi-word string and
687// verifies that the gap between word origins corresponds to the actual
688// glyph-width sum rather than the inflated bbox.
689BOOST_AUTO_TEST_CASE( StrokeFontWordSpacingMatchesGlyphAdvance )
690{
691 wxString pdfPath = getTempPdfPath( "kicad_pdf_wordspacing" );
692
693 PDF_PLOTTER plotter;
694 SIMPLE_RENDER_SETTINGS renderSettings;
695 plotter.SetRenderSettings( &renderSettings );
696 BOOST_REQUIRE( plotter.OpenFile( pdfPath ) );
697 plotter.SetViewport( VECTOR2I( 0, 0 ), 1.0, 1.0, false );
698 BOOST_REQUIRE( plotter.StartPlot( wxT( "1" ), wxT( "WordSpacingTest" ) ) );
699
700 const int sizeIU = 1900; // mimics the 1.9mm silk text from issue #24419
701 const int strokeW = 380; // bold thickness as in the issue file
702 TEXT_ATTRIBUTES attrs = BuildTextAttributes( sizeIU, strokeW, true, false );
705
706 auto strokeFont = LoadStrokeFontUnique();
707 const KIFONT::METRICS metrics;
708
709 plotter.PlotText( VECTOR2I( 50000, 60000 ), COLOR4D( 0, 0, 0, 1 ),
710 wxT( "TEST text Silkscreen" ), attrs, strokeFont.get(), metrics );
711 plotter.EndPlot();
712
713 std::string buffer;
714 BOOST_REQUIRE( ReadPdfWithDecompressedStreams( pdfPath, buffer ) );
715
716 // Each word becomes one `q ... cm BT ... Tj ET Q` block. Capture the X translation (ctm_e)
717 // of each block so we can verify the gap between words.
718 std::vector<double> wordOriginX;
719 std::string::size_type pos = 0;
720
721 while( ( pos = buffer.find( "cm BT", pos ) ) != std::string::npos )
722 {
723 std::string::size_type lineStart = buffer.rfind( '\n', pos );
724 lineStart = ( lineStart == std::string::npos ) ? 0 : lineStart + 1;
725
726 std::string cmLine = buffer.substr( lineStart, pos - lineStart );
727 double a, b, c, d, e, f;
728
729 if( sscanf( cmLine.c_str(), "q %lf %lf %lf %lf %lf %lf", &a, &b, &c, &d, &e, &f ) == 6 )
730 wordOriginX.push_back( e );
731
732 pos += 5;
733 }
734
735 // Expect one block per word: "TEST", "text", "Silkscreen".
736 BOOST_REQUIRE_EQUAL( wordOriginX.size(), 3u );
737
738 // Compute the expected stroke-font cursor advance for "TEST " (word + trailing space) and
739 // "text " in IU. This is what the Tj operator will move the text cursor by and what the
740 // renderWord cursor should match. Compare ratios so we don't have to convert to device
741 // units (userToDeviceSize is protected).
742 auto strokeAdvanceIU = [&]( const wxString& aWord )
743 {
744 return strokeFont
745 ->GetTextAsGlyphs( nullptr, nullptr, aWord, VECTOR2I( sizeIU, sizeIU ),
747 .x;
748 };
749
750 const double expectedTestAdvanceIU = (double) strokeAdvanceIU( wxT( "TEST " ) );
751 const double expectedTextAdvanceIU = (double) strokeAdvanceIU( wxT( "text " ) );
752
753 const double observedTestAdvanceDev = wordOriginX[1] - wordOriginX[0];
754 const double observedTextAdvanceDev = wordOriginX[2] - wordOriginX[1];
755
756 // device-per-IU scale factor derived from the first observed gap.
757 const double scale = observedTestAdvanceDev / expectedTestAdvanceIU;
758
759 // The TEST -> text advance and the text -> Silkscreen advance must both follow the
760 // same IU-to-device scale: if the spacing matches glyph metrics, the second observed
761 // gap equals scale * expectedTextAdvanceIU.
762 const double expectedTextAdvanceDev = scale * expectedTextAdvanceIU;
763 BOOST_CHECK_CLOSE( observedTextAdvanceDev, expectedTextAdvanceDev, 0.5 );
764
765 // Sanity guard against regression to the inflated-bbox formula, which adds 3*thickness IU
766 // of slop per word. Use the second word so this check is independent of the scale derivation
767 // above.
768 const double inflatedTextAdvanceDev = scale * ( expectedTextAdvanceIU + 3.0 * strokeW );
769 BOOST_CHECK_MESSAGE( std::abs( observedTextAdvanceDev - inflatedTextAdvanceDev )
770 > 0.5 * ( inflatedTextAdvanceDev - expectedTextAdvanceDev ),
771 "Word spacing matches the buggy inflated-bbox formula ("
772 << observedTextAdvanceDev << " ~= " << inflatedTextAdvanceDev
773 << "); the renderWord cursor fix appears inactive." );
774
775 MaybeRemoveFile( pdfPath );
776}
777
const char * name
static const ADVANCED_CFG & GetCfg()
Get the singleton instance's config, which is shared by all consumers.
FONT is an abstract base class for both outline and stroke fonts.
Definition font.h:94
static FONT * GetFont(const wxString &aFontName=wxEmptyString, bool aBold=false, bool aItalic=false, const std::vector< wxString > *aEmbeddedFiles=nullptr, bool aForDrawingSheet=false)
Definition font.cpp:143
A color representation with 4 components: red, green, blue, alpha.
Definition color4d.h:101
virtual bool EndPlot() override
virtual bool OpenFile(const wxString &aFullFilename) override
Open or create the plot file aFullFilename.
virtual bool StartPlot(const wxString &aPageNumber) override
The PDF engine supports multiple pages; the first one is opened 'for free' the following are to be cl...
virtual void SetViewport(const VECTOR2I &aOffset, double aIusPerDecimil, double aScale, bool aMirror) override
PDF can have multiple pages, so SetPageSettings can be called with the outputFile open (but not insid...
virtual void PlotText(const VECTOR2I &aPos, const COLOR4D &aColor, const wxString &aText, const TEXT_ATTRIBUTES &aAttributes, KIFONT::FONT *aFont, const KIFONT::METRICS &aFontMetrics, void *aData=nullptr) override
void SetRenderSettings(RENDER_SETTINGS *aSettings)
Definition plotter.h:163
Minimal concrete render settings suitable for plotters in tests.
GR_TEXT_H_ALIGN_T m_Halign
GR_TEXT_V_ALIGN_T m_Valign
static constexpr EDA_ANGLE ANGLE_0
Definition eda_angle.h:411
@ BOLD
Definition font.h:43
double m_PDFStrokeFontXOffset
Horizontal offset factor applied to stroke font glyph coordinates (in EM units) after to compensate m...
double m_PDFStrokeFontYOffset
Vertical offset factor applied to stroke font glyph coordinates (in EM units) after Y inversion to co...
double m_PDFStrokeFontWidthFactor
Stroke font line width factor relative to EM size for PDF stroke fonts.
std::string GetTestDataRootDir()
EDA_ANGLE abs(const EDA_ANGLE &aAngle)
Definition eda_angle.h:400
int CountOccurrences(const std::string &aHaystack, const std::string &aNeedle)
Count occurrences of a substring in a string (overlapping allowed).
bool PdfContains(const std::string &aBuffer, const char *aNeedle)
Convenience contains check.
bool ReadPdfWithDecompressedStreams(const wxString &aPdfPath, std::string &aOutBuffer)
Read a PDF file and append best-effort decompressed contents of any Flate streams to the returned buf...
std::unique_ptr< KIFONT::STROKE_FONT > LoadStrokeFontUnique()
Load the default stroke font and return a unique_ptr for RAII deletion.
TEXT_ATTRIBUTES BuildTextAttributes(int aSizeIu=3000, int aStrokeWidth=300, bool aBold=false, bool aItalic=false)
Build a commonly used set of text attributes for plotting text in tests.
bool RasterizePdfCountDark(const wxString &aPdfPath, int aDpi, int aNearWhiteThresh, long &aOutDarkPixels)
Rasterize a PDF page to PNG using pdftoppm if available and count non-near-white pixels.
wxString MakeTempPdfPath(const wxString &aPrefix)
Make a temporary file path with .pdf extension using a given prefix.
void MaybeRemoveFile(const wxString &aPath, const wxString &aEnvVar=wxT("KICAD_KEEP_TEST_PDF"))
Remove a file unless the given environment variable is set (defaults to KICAD_KEEP_TEST_PDF).
Plotting engines similar to ps (PostScript, Gerber, svg)
const int scale
BOOST_AUTO_TEST_SUITE(CadstarPartParser)
BOOST_REQUIRE(intersection.has_value()==c.ExpectedIntersection.has_value())
BOOST_AUTO_TEST_SUITE_END()
BOOST_AUTO_TEST_CASE(PlotMultilingualAllStylesMappings)
static wxString getTempPdfPath(const wxString &name)
BOOST_CHECK_MESSAGE(totalMismatches==0, std::to_string(totalMismatches)+" board(s) with strategy disagreements")
BOOST_TEST_MESSAGE("\n=== Real-World Polygon PIP Benchmark ===\n"<< formatTable(table))
@ GR_TEXT_H_ALIGN_LEFT
GR_TEXT_V_ALIGN_T
This is API surface mapped to common.types.VertialAlignment.
@ GR_TEXT_V_ALIGN_BOTTOM
@ GR_TEXT_V_ALIGN_CENTER
@ GR_TEXT_V_ALIGN_TOP
VECTOR2< int32_t > VECTOR2I
Definition vector2d.h:683