<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Wadifa Info Data Notes]]></title><description><![CDATA[Wadifa Info Data Notes]]></description><link>https://wadifainfo.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae34a8d22577f9cb63986ef/090008a3-9b7e-479d-acc8-766d5f07d7c9.png</url><title>Wadifa Info Data Notes</title><link>https://wadifainfo.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 23:58:33 GMT</lastBuildDate><atom:link href="https://wadifainfo.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Morocco left GMT+1 in September 2026: how to check your software]]></title><description><![CDATA[We run Wadifa Info, a Moroccan site that lists public-sector recruitment exams. Exam times are local, so a wrong offset for Morocco is a real problem for…]]></description><link>https://wadifainfo.hashnode.dev/morocco-left-gmt-1-in-september-2026-how-to-check-your-software</link><guid isPermaLink="true">https://wadifainfo.hashnode.dev/morocco-left-gmt-1-in-september-2026-how-to-check-your-software</guid><category><![CDATA[Python]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Wadifa Info]]></dc:creator><pubDate>Sat, 31 Oct 2026 10:49:55 GMT</pubDate><content:encoded><![CDATA[<p>We run Wadifa Info, a Moroccan site that lists public-sector recruitment exams. Exam times are local, so a wrong offset for Morocco is a real problem for our readers.</p>
<p>On 20 September 2026 at 02:00, Morocco moved its clocks back to 01:00 and stayed there. The country is now on GMT (UTC+0) all year. The legal basis is decree 2.26.530, published in Official Bulletin no. 7521, which repeals the 2018 decree that created permanent GMT+1.</p>
<h2>What changed for developers</h2>
<p>From 2018 to 2026, Morocco was one of the odd cases in time zone handling. It sat on UTC+1, then dropped to UTC+0 during Ramadan, on dates that moved every year. That rule is gone.</p>
<p>The IANA time zone database carries the change from release 2026c, dated 8 July 2026, for two zones: <code>Africa/Casablanca</code> and <code>Africa/El_Aaiun</code>.</p>
<h2>A one-line test</h2>
<p>Ask your runtime for the offset on a date after the change:</p>
<pre><code class="language-python">from datetime import datetime
from zoneinfo import ZoneInfo

print(datetime(2026, 12, 1, 12, tzinfo=ZoneInfo("Africa/Casablanca")).utcoffset())
# 0:00:00 means your data is current. 1:00:00 means it is old.
</code></pre>
<p>We ran this on one of our own machines while writing this post. It printed <code>1:00:00</code>, because Python was using tzdata 2026b.</p>
<h2>Where it still goes wrong</h2>
<ul>
<li><strong>Bundled time zone data.</strong> Python's <code>tzdata</code> package, npm packages and container images ship their own copy, which only changes when you update them.</li>
<li><strong>Old operating system images.</strong> A server that has not been patched since before July 2026 keeps the old rule. On our own Windows host, we stopped trusting the system zone data for Morocco and work from UTC directly.</li>
<li><strong>Hard-coded Ramadan tables.</strong> Code that switches Morocco to UTC+0 during Ramadan can be removed for dates from 20 September 2026. Keep it for historical dates.</li>
<li><strong>Static text.</strong> Travel guides and company pages often say "Morocco is GMT+1". When we checked about 100 pages on 31 October 2026, we found 28 sites still saying so, including some live clocks.</li>
</ul>
<h2>Time differences now</h2>
<table>
<thead>
<tr>
<th>City</th>
<th>European or US winter</th>
<th>European or US summer</th>
</tr>
</thead>
<tbody><tr>
<td>London</td>
<td>Same time</td>
<td>Morocco 1 h behind</td>
</tr>
<tr>
<td>Paris, Madrid</td>
<td>Morocco 1 h behind</td>
<td>Morocco 2 h behind</td>
</tr>
<tr>
<td>New York</td>
<td>Morocco 5 h ahead</td>
<td>Morocco 4 h ahead</td>
</tr>
<tr>
<td>Dubai</td>
<td>Morocco 4 h behind</td>
<td>Morocco 4 h behind</td>
</tr>
</tbody></table>
<p>The full table and sources are on our page: <a href="https://www.wadifa-info.com/en/morocco-time-zone">Time in Morocco: GMT all year since 20 September 2026</a>.</p>
]]></content:encoded></item><item><title><![CDATA[How long after the deadline do Moroccan civil-service exams publish their lists? 1,315 shortlists measured]]></title><description><![CDATA[Median delays after the application deadline for Moroccan concours: written shortlist 13 days, final results 37, measured on 1,315 lists. Open data, CC BY…]]></description><link>https://wadifainfo.hashnode.dev/how-long-after-the-deadline-do-moroccan-civil-service-exams-publish-their-lists-1-315-shortlists-measured</link><guid isPermaLink="true">https://wadifainfo.hashnode.dev/how-long-after-the-deadline-do-moroccan-civil-service-exams-publish-their-lists-1-315-shortlists-measured</guid><category><![CDATA[#OpenData]]></category><category><![CDATA[data]]></category><dc:creator><![CDATA[Wadifa Info]]></dc:creator><pubDate>Fri, 30 Oct 2026 20:05:42 GMT</pubDate><content:encoded><![CDATA[<p>We run Wadifa Info, a site that lists public-sector recruitment exams (concours) in Morocco in Arabic and French. The most common question we get is not "when is the exam" but "when will the list come out". No administration publishes a target delay, so we measured it.</p>
<h2>What we measure</h2>
<p>Every recruitment notice has an application deadline. After it, the administration publishes up to four lists: the shortlist for the written exam, the shortlist for the oral, the final results and sometimes a waiting list. Since 31 July 2026 we record the day each list appears on the official portals and compute the delay in calendar days after the deadline.</p>
<h2>The numbers (snapshot of 30 October 2026)</h2>
<ul>
<li>Shortlist for the written exam: median 13 days after the deadline (first quartile 7, third quartile 27, n = 1,315 lists).</li>
<li>- Shortlist for the oral: median 27 days (18 to 35, n = 899).</li>
<li>- Final results: median 37 days (24 to 45, n = 310).</li>
<li>- Waiting list: median 37 days (32 to 47, n = 80).</li>
</ul>
<p>By administration the spread is wide: the universities of Agadir and Marrakech shortlist in 6 to 9 days, others take a month. The per-employer table is on the source page and updates daily.</p>
<h2>Caveats</h2>
<p>These are preliminary figures and they are biased towards short delays: a list that has not appeared yet is not in the sample, so the slow cases are under-represented until they close. They are not official dates and should not be read as commitments.</p>
<h2>Get the data</h2>
<ul>
<li>Live tables, method and limits (French, Arabic version linked): <a href="https://www.wadifa-info.com/fr/delai-resultats-concours-maroc">https://www.wadifa-info.com/fr/delai-resultats-concours-maroc</a></li>
<li>- The notices themselves, as JSON with no key: <a href="https://www.wadifa-info.com/en/api">https://www.wadifa-info.com/en/api</a></li>
</ul>
<p>Free to reuse with a link (CC BY 4.0). Corrections: <a href="mailto:contact@wadifa-info.com">contact@wadifa-info.com</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Morocco's Eid is usually a day after Saudi Arabia's: 26 years of official dates]]></title><description><![CDATA[135 Ministry of Habous announcements since 2000: Morocco's Eid falls a day after Saudi Arabia two times out of three. Open CSV, CC BY 4.0.]]></description><link>https://wadifainfo.hashnode.dev/morocco-s-eid-is-usually-a-day-after-saudi-arabia-s-26-years-of-official-dates</link><guid isPermaLink="true">https://wadifainfo.hashnode.dev/morocco-s-eid-is-usually-a-day-after-saudi-arabia-s-26-years-of-official-dates</guid><category><![CDATA[#OpenData]]></category><category><![CDATA[Python]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Wadifa Info]]></dc:creator><pubDate>Fri, 30 Oct 2026 16:11:58 GMT</pubDate><content:encoded><![CDATA[<p>We run Wadifa Info, a Moroccan site that lists public-sector recruitment exams in Arabic and French. Exam dates move around religious holidays, so we needed one thing that turned out not to exist anywhere in a usable form: the dates Morocco actually observed for Ramadan, the two Eids, 1 Muharram and the Prophet's Birthday, year by year.</p>
<h2>The problem</h2>
<p>Almost every holiday API and library (python-holidays, date-holidays, Jollyday, Nager.Date) computes Islamic holidays from the Saudi Umm al-Qura calendar, or from a generic astronomical table. Morocco does not use either. The Ministry of Habous and Islamic Affairs announces the start of each lunar month on the evening of the 29th, after its own sighting committee reports. The announcement is final and the public holiday follows it.</p>
<p>So the question was simple: how often does Morocco differ from Saudi Arabia, and by how much?</p>
<h2>What we did</h2>
<p>We collected the official Moroccan date of 1 Ramadan, Eid al-Fitr, Eid al-Adha, 1 Muharram and Mawlid for every year from 2000 to 2026: 135 dates. Each one comes from a press report of the ministry's announcement (MAP, Medias24, Le360, Hespress, TelQuel, SNRT), and for 2000-2013 from the ICOP archive, which records the first official day per country. Nothing is computed. Four dates we could not source were dropped rather than estimated.</p>
<p>Next to each Moroccan date we put the Saudi date and the difference in days.</p>
<h2>What the numbers say</h2>
<ul>
<li>Morocco was at least one day behind Saudi Arabia 91 times out of 135.</li>
<li>- The two countries were on the same day 44 times.</li>
<li>- Morocco was never ahead.</li>
<li>- Since 2020 the gap has been the rule: every Eid al-Adha and every Mawlid from 2021 to 2025 fell a day after the Saudi date.</li>
</ul>
<p>Against the libraries: python-holidays 0.107 and date-holidays 3.38 are one day early for 71 to 73 of the 110 dates we could compare; Jollyday for 73; the Ruby holidays gem only knows the two Eids; <a href="http://Nager.Date">Nager.Date</a> has no religious holidays for Morocco at all.</p>
<h2>Why it matters in practice</h2>
<p>If you run payroll, schedule deliveries or publish a calendar for Morocco, "Eid al-Fitr: 20 March 2026" from a library is a guess that is wrong about two times out of three. For Morocco the honest answer before the announcement is "20 or 21 March, confirmed on the evening of the 19th".</p>
<h2>Get the data</h2>
<ul>
<li>The full table with a source for every row, CSV under CC BY 4.0: <a href="https://www.wadifa-info.com/en/morocco-islamic-holiday-dates-2000-2026">https://www.wadifa-info.com/en/morocco-islamic-holiday-dates-2000-2026</a></li>
<li>- Current-year holidays as JSON, no key: <a href="https://www.wadifa-info.com/api/v1/jours-feries">https://www.wadifa-info.com/api/v1/jours-feries</a></li>
<li>- The same dataset on Hugging Face: <a href="https://huggingface.co/datasets/wadifainfo/morocco-religious-holidays-2000-2026">https://huggingface.co/datasets/wadifainfo/morocco-religious-holidays-2000-2026</a></li>
</ul>
<p>If you maintain a holiday library and want the Morocco-specific dates, the CSV is the file to import. Corrections welcome at <a href="mailto:contact@wadifa-info.com">contact@wadifa-info.com</a>.</p>
]]></content:encoded></item><item><title><![CDATA[OCR for 1,700 scanned exam papers in Arabic and French with Tesseract (and the traps we hit)]]></title><description><![CDATA[We run Wadifa Info, 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…]]></description><link>https://wadifainfo.hashnode.dev/ocr-for-1-700-scanned-exam-papers-in-arabic-and-french-with-tesseract-and-the-traps-we-hit</link><guid isPermaLink="true">https://wadifainfo.hashnode.dev/ocr-for-1-700-scanned-exam-papers-in-arabic-and-french-with-tesseract-and-the-traps-we-hit</guid><category><![CDATA[Python]]></category><category><![CDATA[OCR ]]></category><category><![CDATA[Arabic ]]></category><dc:creator><![CDATA[Wadifa Info]]></dc:creator><pubDate>Thu, 29 Oct 2026 13:53:07 GMT</pubDate><content:encoded><![CDATA[<p>We run <a href="https://www.wadifa-info.com/">Wadifa Info</a>, 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 (<a href="https://www.wadifa-info.com/fr/modeles-concours">French index</a>).</p>
<p>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.</p>
<h2>Setup: Tesseract 5 with the right language data</h2>
<p>Tesseract 5.4 installs on Windows with one command:</p>
<pre><code class="language-shell">winget install --id UB-Mannheim.TesseractOCR -e
</code></pre>
<p>It ships with English only. Arabic and French data come from the <code>tessdata_fast</code> and <code>tessdata_best</code> repositories, and you point Tesseract at them with <code>--tessdata-dir</code> (handy when you can't write to Program Files).</p>
<p>We measured the options on a real bilingual page, counting recognised Arabic and Latin characters:</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Arabic chars</th>
<th>French chars</th>
</tr>
</thead>
<tbody><tr>
<td><code>ara+fra</code> (fast models), raw image</td>
<td>674</td>
<td>914</td>
</tr>
<tr>
<td><code>ara_best+fra</code>, preprocessed</td>
<td><strong>805</strong></td>
<td><strong>998</strong></td>
</tr>
<tr>
<td><code>ara_best</code> alone</td>
<td>good</td>
<td>French destroyed</td>
</tr>
</tbody></table>
<p>Two lessons: the <code>best</code> Arabic model is clearly better on photographed pages, and a single-language model mangles the other script, so bilingual pages need both (<code>-l ara_best+fra</code>).</p>
<h2>Preprocessing that actually helped</h2>
<p>Three cheap steps, in Pillow, before every page:</p>
<pre><code class="language-python">def prep(src):
    im = Image.open(src).convert("L")                  # grey
    if im.width &lt; 1800:                                # small phone photos
        im = im.resize((im.width * 2, im.height * 2), Image.LANCZOS)
    return ImageOps.autocontrast(im)
</code></pre>
<p>Then <code>tesseract page.jpg stdout -l ara_best+fra --psm 6</code>. 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.</p>
<p>On the first pass this filled 286 of 289 papers that had no text, median about 2,900 characters per paper.</p>
<h2>Trap 1: photographed pages that return only our own footer</h2>
<p>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 <code>--psm</code>, at any scale. The page wasn't blurry; it had uneven lighting, so a global threshold turned half the sheet into one dark blob.</p>
<p>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.</p>
<pre><code class="language-python">bg   = im.filter(ImageFilter.GaussianBlur(20))
diff = ImageChops.subtract(bg, im)            # ink is darker than the local background
th   = diff.point(lambda v: 0 if v &gt; 12 else 255)
</code></pre>
<p>We now try four variants per page (plain or adaptive, <code>--psm 4</code> or <code>6</code>) 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.</p>
<p><strong>Scoring trap inside the trap:</strong> we first stripped footer <em>lines</em> before scoring the old text. A one-line OCR result <em>is</em> the footer line, so stripping it scored the old text as zero and every comparison looked like a huge win. Strip the footer <em>phrase</em>, not the line.</p>
<h2>Trap 2: a page cap we forgot about</h2>
<p>The first script had <code>MAX_PAGES = 8</code> 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.</p>
<p>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 (<code>— صفحة N —</code>), so a 12-page paper whose text stops at page 8 is easy to spot. We now run that check after every import.</p>
<h2>Trap 3: papers that are only half there</h2>
<p>This one wasn't an OCR bug; OCR found it. Many official papers print their own footer: <code>Page 2 sur 5</code>, or <code>الصفحة 2 من 5</code>. If the paper says "page 1 of 9" and we publish 4 images, we are missing pages.</p>
<p>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.</p>
<p>Two things made the detector usable:</p>
<ol>
<li><strong>Never match a bare</strong> <code>N/M</code><strong>.</strong> Moroccan exams are marked out of 20 or 10, so <code>14/20</code> is a score, not a page number. The naive pattern produced 34 candidates, almost all wrong. Only explicit forms count: <code>Page N sur M</code>, <code>Page N | M</code>, <code>الصفحة N من M</code>.</li>
<li><strong>Read the context.</strong> "Répondre sur le document réponse (page 06/10)" is a cross-reference, not a footer.</li>
</ol>
<h2>Trap 4: duplicates that look different, and different papers that look the same</h2>
<p>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.</p>
<p>What worked was comparing the <strong>full</strong> OCR text at the word level:</p>
<pre><code class="language-python">from difflib import SequenceMatcher
same = SequenceMatcher(None, a.split(), b.split()).ratio() &gt;= 0.84
</code></pre>
<p>Above 0.84 it was always a true copy in our checks; below it, a genuinely different paper. Seven copies were hidden this way.</p>
<h2>Where it ended up</h2>
<ul>
<li>Every published paper has text, except a handful of genuinely unreadable photos that we leave empty on purpose rather than fill with noise.</li>
<li>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.</li>
<li>The same text drives our duplicate check when new papers come in.</li>
<li>The health checks (page count vs. last marker, very short texts, printed "Page N sur M") run after every batch.</li>
</ul>
<p>If you're digitising scanned documents in Arabic, or any mixed-script material, the short version is: use the <code>best</code> 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.</p>
<p>The papers themselves are free to browse at <a href="https://www.wadifa-info.com/fr/modeles-concours">wadifa-info.com</a>.</p>
<hr />
<p><em>Written with the help of an AI assistant from our own code and logs; all figures are from our production data.</em></p>
]]></content:encoded></item><item><title><![CDATA[One C# renderer for every social image: job cards in Arabic and French with GDI+]]></title><description><![CDATA[We run Wadifa Info, a Moroccan site that lists public-sector recruitment exams and job offers in Arabic and French. Every job page needs a preview image…]]></description><link>https://wadifainfo.hashnode.dev/one-c-renderer-for-every-social-image-job-cards-in-arabic-and-french-with-gdi</link><guid isPermaLink="true">https://wadifainfo.hashnode.dev/one-c-renderer-for-every-social-image-job-cards-in-arabic-and-french-with-gdi</guid><category><![CDATA[C#]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Wadifa Info]]></dc:creator><pubDate>Thu, 29 Oct 2026 13:50:16 GMT</pubDate><content:encoded><![CDATA[<p>We run <a href="https://www.wadifa-info.com/">Wadifa Info</a>, 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.</p>
<p>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 <a href="http://ASP.NET">ASP.NET</a> MVC and <code>System.Drawing</code> (GDI+).</p>
<h2>One card model, three formats</h2>
<p>The renderer takes a small object (institution, job title, specialty, number of posts, deadline, language) and a canvas size:</p>
<ul>
<li><code>og</code> 1200×630 for link previews</li>
<li><code>sq</code> 1080×1080 for social posts</li>
<li><code>pt</code> 1080×1350 for portrait feeds</li>
</ul>
<p>Nothing in the layout is hard-coded to a size. Everything is expressed in a unit <code>U = min(width, height) / 100</code>, 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.</p>
<pre><code class="language-csharp">bool wide = W &gt;= H * 1.5f;
var c = new WfCtx(g) { U = Math.Min(W, H) / 100f, Rtl = d.Rtl };
</code></pre>
<p>Adding the portrait format later was one line in the size switch. The layout code did not change.</p>
<h2>Text that always fits</h2>
<p>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.</p>
<p><code>WFit</code> 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:</p>
<pre><code class="language-csharp">static Font WFit(WfCtx c, string text, FontFamily fam, float maxPx, float minPx,
                 float maxW, int maxLines, out List&lt;string&gt; lines)
{
    float step = Math.Max(2, (maxPx - minPx) / 10);
    for (float px = maxPx; px &gt;= minPx; px -= step)
    {
        var f = c.F(fam, px); var ls = WWrap(c, text, f, maxW);
        if (ls.Count &lt;= maxLines &amp;&amp; ls.All(l =&gt; WTW(c, l, f) &lt;= maxW * 1.001f))
        { lines = ls; return f; }
    }
    // still too long at the minimum size: keep maxLines and end with an ellipsis
    ...
}
</code></pre>
<p>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:</p>
<pre><code class="language-csharp">float w = WTW(c, s, f), sx = (maxW &gt; 0 &amp;&amp; w &gt; maxW) ? maxW / w : 1f;
c.G.TranslateTransform(left, y);
if (sx &lt; 1) c.G.ScaleTransform(sx, 1);
</code></pre>
<p>A slightly condensed word looks far better than a clipped one.</p>
<h2>Arabic and French in the same card</h2>
<p>Two things made bilingual rendering work:</p>
<ol>
<li><strong>Direction per string, not per card.</strong> 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 <code>StringFormat</code> if it contains Arabic characters, and left-to-right otherwise. GDI+ then does the shaping and joins the letters correctly.</li>
<li><strong>One font family for both scripts.</strong> We load Tajawal (an open-licence family with Arabic and Latin glyphs) from the app's own folder with <code>PrivateFontCollection</code>, 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.</li>
</ol>
<p>The whole layout mirrors for Arabic: the art panel, the alignment anchor, the arrow in the call-to-action button.</p>
<p>One GDI+ detail to know: <code>Font</code> and <code>StringFormat</code> objects are not thread-safe, so they live in a per-request context object, never in static fields.</p>
<h2>Variety without randomness</h2>
<p>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:</p>
<pre><code class="language-csharp">static uint WfHash(string s)
{ uint h = 2166136261; foreach (char ch in s ?? "") { h ^= ch; h *= 16777619; } return h; }

t = cands[(int)(seed % (uint)cands.Count)];
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>Do not render arbitrary text on your domain</h2>
<p>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:</p>
<pre><code class="language-csharp">if (t.Length == 0 || t.Length &gt; 120 || !string.Equals(s, WfSig(t), StringComparison.Ordinal))
    return Redirect("/img/og-default");
</code></pre>
<p>If you build an image endpoint that takes text from the query string, do this from day one.</p>
<h2>Caching</h2>
<p>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.</p>
<h2>What we would do differently</h2>
<ul>
<li><code>System.Drawing</code> is Windows-only in modern .NET. It suits our hosting, but on Linux we would start with SkiaSharp.</li>
<li>We would design the portrait format first. Link previews are forgiving; tall formats expose every spacing mistake.</li>
<li>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.</li>
</ul>
<p>You can see the result on any page of the site, for example the <a href="https://www.wadifa-info.com/fr/grille-salariale-fonction-publique-maroc">civil-service salary grid</a>: its link preview is one of these cards.</p>
<p>Have you built server-side image generation with right-to-left text? We would like to hear how you handled mixed-direction lines.</p>
]]></content:encoded></item></channel></rss>