<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wadifa Info</title>
    <description>The latest articles on DEV Community by Wadifa Info (@wadifainfo).</description>
    <link>https://dev.to/wadifainfo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4152371%2F92e4128a-f166-4d0c-9c75-4cb31c37d532.png</url>
      <title>DEV Community: Wadifa Info</title>
      <link>https://dev.to/wadifainfo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wadifainfo"/>
    <language>en</language>
    <item>
      <title>OCR for 1,700 scanned exam papers in Arabic and French with Tesseract (and the traps we hit)</title>
      <dc:creator>Wadifa Info</dc:creator>
      <pubDate>Thu, 01 Oct 2026 08:51:15 +0000</pubDate>
      <link>https://dev.to/wadifainfo/ocr-for-1700-scanned-exam-papers-in-arabic-and-french-with-tesseract-and-the-traps-we-hit-1bgi</link>
      <guid>https://dev.to/wadifainfo/ocr-for-1700-scanned-exam-papers-in-arabic-and-french-with-tesseract-and-the-traps-we-hit-1bgi</guid>
      <description>&lt;p&gt;We run &lt;a href="https://www.wadifa-info.com/" rel="noopener noreferrer"&gt;Wadifa Info&lt;/a&gt;, a Moroccan site that lists public-sector recruitment exams in Arabic and French. One part of the site is a free library of past exam papers: more than 1,700 of them, from police and customs exams to teacher-training and engineering-school entrance exams (&lt;a href="https://www.wadifa-info.com/fr/modeles-concours" rel="noopener noreferrer"&gt;French index&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Most of these papers arrive as photos or scans. A page that is only an image can't be searched, can't be quoted in a page description, and tells a search engine almost nothing. So every page needs its text. This post covers how we OCR'd the whole library on one Windows machine with Tesseract, and the four traps that made "done" turn out to be not done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: Tesseract 5 with the right language data
&lt;/h2&gt;

&lt;p&gt;Tesseract 5.4 installs on Windows with one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;winget &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--id&lt;/span&gt; UB-Mannheim.TesseractOCR &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ships with English only. Arabic and French data come from the &lt;code&gt;tessdata_fast&lt;/code&gt; and &lt;code&gt;tessdata_best&lt;/code&gt; repositories, and you point Tesseract at them with &lt;code&gt;--tessdata-dir&lt;/code&gt; (handy when you can't write to Program Files).&lt;/p&gt;

&lt;p&gt;We measured the options on a real bilingual page, counting recognised Arabic and Latin characters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Arabic chars&lt;/th&gt;
&lt;th&gt;French chars&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ara+fra&lt;/code&gt; (fast models), raw image&lt;/td&gt;
&lt;td&gt;674&lt;/td&gt;
&lt;td&gt;914&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ara_best+fra&lt;/code&gt;, preprocessed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;805&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;998&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ara_best&lt;/code&gt; alone&lt;/td&gt;
&lt;td&gt;good&lt;/td&gt;
&lt;td&gt;French destroyed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two lessons: the &lt;code&gt;best&lt;/code&gt; Arabic model is clearly better on photographed pages, and a single-language model mangles the other script, so bilingual pages need both (&lt;code&gt;-l ara_best+fra&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Preprocessing that actually helped
&lt;/h2&gt;

&lt;p&gt;Three cheap steps, in Pillow, before every page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;im&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# grey
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                &lt;span class="c1"&gt;# small phone photos
&lt;/span&gt;        &lt;span class="n"&gt;im&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LANCZOS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ImageOps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;autocontrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;tesseract page.jpg stdout -l ara_best+fra --psm 6&lt;/code&gt;. Page segmentation mode 6 ("a single uniform block of text") beat the automatic mode on exam sheets, which are mostly dense paragraphs and numbered questions.&lt;/p&gt;

&lt;p&gt;On the first pass this filled 286 of 289 papers that had no text, median about 2,900 characters per paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: photographed pages that return only our own footer
&lt;/h2&gt;

&lt;p&gt;A few phone photos came back with exactly one line: the footer we add to every page. Tesseract found no text blocks at all, at any &lt;code&gt;--psm&lt;/code&gt;, at any scale. The page wasn't blurry; it had uneven lighting, so a global threshold turned half the sheet into one dark blob.&lt;/p&gt;

&lt;p&gt;The fix was an adaptive threshold, done in Pillow without OpenCV: compare each pixel with a heavily blurred copy of the page, and keep only what is darker than its local background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;bg&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ImageFilter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GaussianBlur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageChops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# ink is darker than the local background
&lt;/span&gt;&lt;span class="n"&gt;th&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We now try four variants per page (plain or adaptive, &lt;code&gt;--psm 4&lt;/code&gt; or &lt;code&gt;6&lt;/code&gt;) and keep the one with the most real words. "Real word" is deliberately crude: Arabic words of three letters or more, and Latin words of three letters or more that contain a vowel. Our worst three papers went from a single footer line to 4,800–5,600 characters each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scoring trap inside the trap:&lt;/strong&gt; we first stripped footer &lt;em&gt;lines&lt;/em&gt; before scoring the old text. A one-line OCR result &lt;em&gt;is&lt;/em&gt; the footer line, so stripping it scored the old text as zero and every comparison looked like a huge win. Strip the footer &lt;em&gt;phrase&lt;/em&gt;, not the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: a page cap we forgot about
&lt;/h2&gt;

&lt;p&gt;The first script had &lt;code&gt;MAX_PAGES = 8&lt;/code&gt; to keep test runs short. It never came out. Fifty-eight papers longer than eight pages had pages 9+ silently skipped, about 400 pages in total.&lt;/p&gt;

&lt;p&gt;What caught it was a simple health check: for each paper, compare the number of images we publish with the highest page marker in its text. Our OCR writes a marker between pages (&lt;code&gt;— صفحة N —&lt;/code&gt;), so a 12-page paper whose text stops at page 8 is easy to spot. We now run that check after every import.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: papers that are only half there
&lt;/h2&gt;

&lt;p&gt;This one wasn't an OCR bug; OCR found it. Many official papers print their own footer: &lt;code&gt;Page 2 sur 5&lt;/code&gt;, or &lt;code&gt;الصفحة 2 من 5&lt;/code&gt;. If the paper says "page 1 of 9" and we publish 4 images, we are missing pages.&lt;/p&gt;

&lt;p&gt;Matching that pattern across the library found nine incomplete papers. One of them showed only "Page 2 sur 2": visitors were opening the paper halfway through, at exercise 3.&lt;/p&gt;

&lt;p&gt;Two things made the detector usable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never match a bare &lt;code&gt;N/M&lt;/code&gt;.&lt;/strong&gt; Moroccan exams are marked out of 20 or 10, so &lt;code&gt;14/20&lt;/code&gt; is a score, not a page number. The naive pattern produced 34 candidates, almost all wrong. Only explicit forms count: &lt;code&gt;Page N sur M&lt;/code&gt;, &lt;code&gt;Page N | M&lt;/code&gt;, &lt;code&gt;الصفحة N من M&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the context.&lt;/strong&gt; "Répondre sur le document réponse (page 06/10)" is a cross-reference, not a footer.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Trap 4: duplicates that look different, and different papers that look the same
&lt;/h2&gt;

&lt;p&gt;The same paper often reaches us twice: a clean PDF and a phone photo of a printout, or the same engineering-school subject adapted for two streams. Image hashing on a page region gave false positives (shared headers), and comparing only the first 3,000 characters did too: the adapted version shares the opening, then diverges.&lt;/p&gt;

&lt;p&gt;What worked was comparing the &lt;strong&gt;full&lt;/strong&gt; OCR text at the word level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;difflib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SequenceMatcher&lt;/span&gt;
&lt;span class="n"&gt;same&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SequenceMatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.84&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Above 0.84 it was always a true copy in our checks; below it, a genuinely different paper. Seven copies were hidden this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it ended up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every published paper has text, except a handful of genuinely unreadable photos that we leave empty on purpose rather than fill with noise.&lt;/li&gt;
&lt;li&gt;Each paper page shows the transcription in a collapsed "text of the questions" box, with a note that it was extracted automatically and the images remain the reference. Candidates can search and copy the questions, and search engines can finally read what each paper covers.&lt;/li&gt;
&lt;li&gt;The same text drives our duplicate check when new papers come in.&lt;/li&gt;
&lt;li&gt;The health checks (page count vs. last marker, very short texts, printed "Page N sur M") run after every batch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're digitising scanned documents in Arabic, or any mixed-script material, the short version is: use the &lt;code&gt;best&lt;/code&gt; model, always pair the scripts, preprocess, keep an adaptive-threshold fallback, and never trust "100% done" until a check you didn't write for the happy path agrees.&lt;/p&gt;

&lt;p&gt;The papers themselves are free to browse at &lt;a href="https://www.wadifa-info.com/fr/modeles-concours" rel="noopener noreferrer"&gt;wadifa-info.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with the help of an AI assistant from our own code and logs; all figures are from our production data.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ocr</category>
      <category>tesseract</category>
      <category>arabic</category>
    </item>
    <item>
      <title>One C# renderer for every social image: job cards in Arabic and French with GDI+</title>
      <dc:creator>Wadifa Info</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:23:02 +0000</pubDate>
      <link>https://dev.to/wadifainfo/one-c-renderer-for-every-social-image-job-cards-in-arabic-and-french-with-gdi-530f</link>
      <guid>https://dev.to/wadifainfo/one-c-renderer-for-every-social-image-job-cards-in-arabic-and-french-with-gdi-530f</guid>
      <description>&lt;p&gt;We run &lt;a href="https://www.wadifa-info.com/" rel="noopener noreferrer"&gt;Wadifa Info&lt;/a&gt;, a Moroccan site that lists public-sector recruitment exams and job offers in Arabic and French. Every job page needs a preview image: for link previews on WhatsApp and Facebook, for square social posts, and for portrait pins. We used to have a white rectangle with a logo. Now one renderer draws all of them on the server, from the job's own data, with no design tool and no stock photos.&lt;/p&gt;

&lt;p&gt;This post covers the parts that were harder than expected: fitting text of unknown length, Arabic right-to-left, picking a layout without randomness, and not letting the endpoint become an abuse vector. The stack is plain ASP.NET MVC and &lt;code&gt;System.Drawing&lt;/code&gt; (GDI+).&lt;/p&gt;

&lt;h2&gt;
  
  
  One card model, three formats
&lt;/h2&gt;

&lt;p&gt;The renderer takes a small object (institution, job title, specialty, number of posts, deadline, language) and a canvas size:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;og&lt;/code&gt; 1200×630 for link previews&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sq&lt;/code&gt; 1080×1080 for social posts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pt&lt;/code&gt; 1080×1350 for portrait feeds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing in the layout is hard-coded to a size. Everything is expressed in a unit &lt;code&gt;U = min(width, height) / 100&lt;/code&gt;, and the layout is decided from the shape: a wide canvas gets a text column with an art panel beside it, a tall canvas gets an art band on top with the text stacked below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;wide&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1.5f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;WfCtx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;100f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Rtl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rtl&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding the portrait format later was one line in the size switch. The layout code did not change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text that always fits
&lt;/h2&gt;

&lt;p&gt;A job title can be "Technicien" or a 120-character ministry name. The rule we settled on is: shrink, then wrap, then drop optional rows, then ellipsis.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WFit&lt;/code&gt; tries font sizes from the maximum down to the minimum, wraps at each size, and returns the first size where the text fits the allowed number of lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Font&lt;/span&gt; &lt;span class="nf"&gt;WFit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WfCtx&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FontFamily&lt;/span&gt; &lt;span class="n"&gt;fam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;maxPx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;minPx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;maxW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxLines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxPx&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;minPx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxPx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;minPx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;F&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;WWrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxW&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maxLines&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;All&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;WTW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maxW&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1.001f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// still too long at the minimum size: keep maxLines and end with an ellipsis&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One case survives all of that: a single word wider than the column at the minimum size. For that, the draw call condenses the line horizontally instead of overflowing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;WTW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxW&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;maxW&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TranslateTransform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sx&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScaleTransform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A slightly condensed word looks far better than a clipped one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arabic and French in the same card
&lt;/h2&gt;

&lt;p&gt;Two things made bilingual rendering work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Direction per string, not per card.&lt;/strong&gt; A card in Arabic can contain a Latin acronym, and a French card can contain an Arabic institution name. Each string is measured and drawn with a right-to-left &lt;code&gt;StringFormat&lt;/code&gt; if it contains Arabic characters, and left-to-right otherwise. GDI+ then does the shaping and joins the letters correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One font family for both scripts.&lt;/strong&gt; We load Tajawal (an open-licence family with Arabic and Latin glyphs) from the app's own folder with &lt;code&gt;PrivateFontCollection&lt;/code&gt;, so the server does not need any font installed. Arabic lines also get more line height (1.30 against 1.14), because the letterforms need it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole layout mirrors for Arabic: the art panel, the alignment anchor, the arrow in the call-to-action button.&lt;/p&gt;

&lt;p&gt;One GDI+ detail to know: &lt;code&gt;Font&lt;/code&gt; and &lt;code&gt;StringFormat&lt;/code&gt; objects are not thread-safe, so they live in a per-request context object, never in static fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Variety without randomness
&lt;/h2&gt;

&lt;p&gt;A feed where every card looks identical is dull, but a card that changes every time it is fetched breaks caching and confuses people who share it. So the template is chosen from a stable hash of the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;uint&lt;/span&gt; &lt;span class="nf"&gt;WfHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;uint&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2166136261&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="p"&gt;^=&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="p"&gt;*=&lt;/span&gt; &lt;span class="m"&gt;16777619&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The candidate list depends on the data. A card only gets the "key figure" template if there is a real figure to show (number of posts, days left when the deadline is within five days). There is no invented urgency: the status badge is computed from the real deadline.&lt;/p&gt;

&lt;p&gt;The background art is vector, drawn in code: a network for IT jobs, arches for teaching, a pulse line for health, an eight-point star lattice as the default. Recently we added a "document" drawing (a white sheet with a CV-like skeleton) for pages that are about a document, such as CV and cover-letter templates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not render arbitrary text on your domain
&lt;/h2&gt;

&lt;p&gt;Generic pages (guides, salary tables) also get a card, built from the page title. The title travels in the image URL, which means anyone could request an image with any text on it, served from our domain. So the title is signed with an HMAC, and the endpoint refuses anything whose signature does not match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;WfSig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/img/og-default"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you build an image endpoint that takes text from the query string, do this from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching
&lt;/h2&gt;

&lt;p&gt;Rendering takes real CPU, so each image is written to disk under a key made of every input that affects the pixels, and served as a static file afterwards. Cards near a deadline include the date in the key, because their "N days left" badge changes daily. Everything else is sent with a one-week cache header.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;System.Drawing&lt;/code&gt; is Windows-only in modern .NET. It suits our hosting, but on Linux we would start with SkiaSharp.&lt;/li&gt;
&lt;li&gt;We would design the portrait format first. Link previews are forgiving; tall formats expose every spacing mistake.&lt;/li&gt;
&lt;li&gt;An earlier, unrelated endpoint on the site accepted unsigned tokens and had to be fixed after an audit. That is why this one was signed from its first version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can see the result on any page of the site, for example the &lt;a href="https://www.wadifa-info.com/fr/grille-salariale-fonction-publique-maroc" rel="noopener noreferrer"&gt;civil-service salary grid&lt;/a&gt;: its link preview is one of these cards.&lt;/p&gt;

&lt;p&gt;Have you built server-side image generation with right-to-left text? We would like to hear how you handled mixed-direction lines.&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
