Scripting Government PDF Forms and Making Safer ID Copies
An ID copy that does not say what it is for can be reused for anything. A watermark stating its purpose is not decoration; it is the cheapest layer of protection I can add myself.
I recently had a set of documents to submit to a government agency, and I needed to repeat the process several times in quick succession. Each submission involved two or three forms and several attachments. Filling them out manually each time was slow, and I made typing mistakes every time too. So I wrote a script to fill everything out with a single command.
This is not about creating a new document from a web page and printing it, which is another approach I have written about before. This is a different kind of job: I have the government's form file, I cannot change its appearance or recreate it, and I have to fill in the fields provided, then print it for submission.
I made up a completely new set of first names, surnames, ID numbers, addresses, dates, and application numbers for this article so they are clearly recognizable as examples. The techniques, coordinates, and code, however, come from the actual work.
A fillable form is not necessarily a form that supports Thai
Most government forms available for download really do have embedded fields that can be filled in using a document reader. The problem is that those fields were created with fonts that do not contain a complete set of Thai characters. Filling them with Thai text and letting the software render it produces either empty fields or rows of square boxes.
This order matters a great deal, especially where the flattening step falls. If I draw the Thai text first and flatten afterward, the field appearances will be drawn over the Thai text, hiding it completely. I have to flatten the fields into actual page content first, then draw my text on top.
List the field names first; do not guess
Field names in government forms are almost meaningless because the people creating them use names assigned automatically by the software. They look something like Text25 or Check Box19. The only thing to do is list them all with their coordinates, then match them visually against a printed page.
# dump_fields.py — ไล่ชื่อช่องทั้งหมดพร้อมพิกัด เรียงจากบนลงล่าง ซ้ายไปขวา
import fitz
doc = fitz.open('form_template.pdf')
for pno, page in enumerate(doc, start=1):
widgets = list(page.widgets())
print('===== หน้า', pno, ':', len(widgets), 'ช่อง')
# พิกัด y ของ PDF นับจากล่างขึ้นบน แปลงเป็นนับจากบนลงล่างให้ตรงกับที่ตาเห็น
for w in sorted(widgets, key=lambda w: (-w.rect.y1, w.rect.x0)):
r = [round(v) for v in w.rect]
ytop = round(page.rect.height) - r[3]
extra = ''
if w.field_type_string == 'RadioButton':
extra = ' options=' + str(w.button_states())
print('y%3d x%3d-%3d h%2d %-10s %s%s'
% (ytop, r[0], r[2], r[3] - r[1], w.field_type_string, w.field_name, extra))
The result is a long list that is very quick to match against the paper because it is already sorted in the order my eyes follow. I printed one blank form and wrote each field name beside its field in pencil. It took less than twenty minutes per form, and afterward I never had to guess again.
Checkboxes in forms do not always accept Boolean values. In some files, the correct value is a string such as Yes or On, or even the name of the option itself. If setting a value does not check the box, list the states that field accepts first, then use a value it accepts. Do not argue with the file.
Fill the fields, then flatten them into actual page content
The filling step is straightforward: iterate over all the fields, and if a name matches one I have prepared, set its value. One thing to watch for is the reset button included in many government forms. If I leave it there, it goes along with the submitted file, and if someone clicks it, all the data in the form disappears. I always delete it before flattening.
# fill_form.py — กรอกผ่านช่อง แล้วอบให้กลายเป็นเนื้อเอกสาร แก้ต่อไม่ได้
import fitz
TEXT_FIELDS = {
'Text25': 'Mr. Somchai Tuayang', # ชื่อภาษาอังกฤษ
'Text26': '99 Moo 9, Tambon Tuayang, Mueang District,',
'Text27': 'Sample Province 10000, Thailand',
'Text78': 'Singapore', # ประเทศปลายทาง
'Text79.0': '2568', # ปีภาษี
'Text98.1': '3', # จำนวนเอกสารแนบ
}
CHECK_FIELDS = {'Check Box2', 'Check Box6.0', 'Check Box19', 'Check Box20'}
doc = fitz.open('form_template.pdf')
page = doc[0]
for w in page.widgets():
if w.field_name in TEXT_FIELDS:
w.field_value = TEXT_FIELDS[w.field_name]
w.update()
elif w.field_name in CHECK_FIELDS and w.field_type_string == 'CheckBox':
w.field_value = True
w.update()
# ลบปุ่มล้างข้อมูลที่ฟอร์มแถมมา ก่อนอบ
for w in list(page.widgets()):
if w.field_type_string == 'Button':
page.delete_widget(w)
doc.bake() # ช่องกรอกกลายเป็นเนื้อเอกสาร พิมพ์แล้วไม่หาย และแก้กลับไม่ได้
page = doc[0] # หลังอบแล้ว ต้องหยิบหน้าใหม่ อ้างอิงเดิมใช้ไม่ได้
This confused me for quite a while because the code reported no errors, but the text drawn after flattening did not appear in the output file. The cause was that the page variable still pointed to the old structure, which had been replaced. Always retrieve the page from the document again after flattening.
Subdivided fields need spaces in the separator boxes The ID number field in a government form is often a single field divided by lines into a row of small boxes, with separator boxes between groups of digits. If I enter all the digits as one continuous string, they run over the dividing lines because the software counts every box equally, including the separator boxes.
The fix is to insert spaces at the separator positions myself so the total character count exactly matches the number of boxes.
# เลขประจำตัวตัวอย่าง 13 หลัก ในช่องที่แบ่งเป็น 17 กล่อง
# รูปแบบกลุ่มคือ 1-4-5-2-1 และมีช่องขั้นคั่น 4 ช่อง รวมเป็น 17 กล่อง
SAMPLE_ID = '3123456789012' # ตัวอย่างล้วน ไม่ใช่เลขของใคร
comb = '%s %s %s %s %s' % (SAMPLE_ID[0], SAMPLE_ID[1:5], SAMPLE_ID[5:10],
SAMPLE_ID[10:12], SAMPLE_ID[12])
assert len(comb) == 17, 'จำนวนอักขระต้องเท่ากับจำนวนกล่องพอดี'
TEXT_FIELDS['Text102'] = comb
I added the length check at the bottom because I had made this mistake before: I changed the digit grouping and forgot to count again. The resulting file looked fine, but the digits were shifted by one box across the row. I missed it at a glance and only noticed when an official pointed it out.
Thai text needs manual drawing and manual tone mark positioning
After flattening the document, I can draw text directly onto it using my own font, choosing one with a complete set of Thai characters.
FONT_TH = r'C:/Windows/Fonts/leelawad.ttf'
# (x, y จากขอบล่าง, ข้อความ, ขนาด) — ได้พิกัดจากรายการช่องที่ไล่ไว้ตอนแรก
THAI_TEXT = [
(452, 48, '4', 11), # เลขหลังเส้นประหัวฟอร์ม
(400, 483, 'มกราคม', 10), # เดือนเริ่ม
(518, 483, 'ธันวาคม', 10), # เดือนสิ้นสุด
(305, 784, 'นายสมชาย ตัวอย่าง', 11), # ชื่อในวงเล็บใต้ลายเซ็น
]
for x, y, s, size in THAI_TEXT:
page.insert_text((x, y), s, fontname='leela', fontfile=FONT_TH, fontsize=size)
doc.save('form_filled.pdf', deflate=True)
Now comes a problem everyone who generates Thai documents programmatically encounters: when a word has a vowel above the line and a tone mark stacked above it, such as a mai ek tone mark above sara i, the tone mark overlaps the vowel and makes it unreadable. This happens because the library draws characters one by one without knowing which should sit higher than which.
My fix is to draw each character myself and raise the tone mark when the preceding character is an above-line vowel.
# -*- coding: utf-8 -*-
from reportlab.pdfbase import pdfmetrics
UPPER_VOWELS = set('ัิีึื็') # สระที่วางอยู่เหนือพยัญชนะ
RAISED_MARKS = set('่้๊๋์') # วรรณยุกต์และทัณฑฆาตที่ต้องลอยสูงขึ้นถ้าซ้อน
RISE_FACTOR = 0.34 # ยกขึ้นเท่ากับ 34% ของขนาดตัวอักษร
def draw_thai(c, x, y, text, font, size, bg=False):
if bg and text.strip():
w = pdfmetrics.stringWidth(text, font, size)
c.saveState()
c.setFillColorRGB(1, 1, 1) # พื้นขาวทับเส้นประของฟอร์ม
c.rect(x - 2, y - size * 0.28, w + 4, size * 1.38, fill=1, stroke=0)
c.restoreState()
c.setFont(font, size)
cx, prev = x, ''
for ch in text:
if ch in RAISED_MARKS and prev in UPPER_VOWELS:
c.drawString(cx, y + size * RISE_FACTOR, ch) # ยกให้พ้นสระบน
else:
c.drawString(cx, y, ch)
w = pdfmetrics.stringWidth(ch, font, size)
cx += w
# อักขระที่ไม่กินความกว้างไม่นับเป็นตัวก่อนหน้า ไม่งั้นตรรกะเพี้ยน
if w > 0.01 * size or ch not in RAISED_MARKS:
prev = ch
return cx - x
Government forms have dotted writing lines in almost every field. If I simply draw text over them, the dots run through the characters and make them hard to read. Drawing a white rectangle beneath the text first immediately cleans up the result. The rectangle's height must allow for vowels above and below the line too. I use about one and a third times the font size, offset downward from the baseline by about three tenths, which works for most Thai fonts.
An ID copy on A4 must match the actual card size
Almost every document submission needs an ID copy attached. Most people photograph the card with a phone and paste it onto a page. The result is a copy twice the size of the actual card, which officials may accept but looks untidy, and some agencies ask for actual-size prints.
The standard card size is 85.6 by 53.98 millimeters. Converting that to pixels at 300 dots per inch gives dimensions I can use directly.
# -*- coding: utf-8 -*-
from PIL import Image
DPI = 300
A4_PX = (2480, 3508) # A4 ที่ 300 dpi
CARD_PX = (1011, 638) # 85.6 x 53.98 mm ที่ 300 dpi = ขนาดบัตรจริง
card = Image.open('card_scan.jpg').resize(CARD_PX, Image.LANCZOS)
page = Image.new('RGBA', A4_PX, (255, 255, 255, 255))
cx = (A4_PX[0] - CARD_PX[0]) // 2 # กึ่งกลางแนวนอน
cy = 520 # เว้นหัวกระดาษไว้พอสมควร
page.paste(card, (cx, cy))
I insist on a minimum resolution of 300 dots per inch. Below that, the small text on the card becomes unreadable after printing. Above that, the file gets larger without adding detail a typical office printer can reproduce anyway.
A purpose-specific watermark is the cheapest layer of protection
A signed, certified ID copy with no stated purpose can be reused for anything: opening an account, signing up for services, or submitting something elsewhere on my behalf, without my ever knowing. A watermark stating what it is for and including a date prevents that document from being used for another purpose, and if someone tries, the misuse is immediately visible. It costs twenty lines of code to do, which is well worth it compared with the potential damage.
My watermark has two lines: the purpose on top and the date below. I draw it on a separate transparent layer, then rotate it before overlaying it only on the card area, rather than the entire page, because a full-page watermark would also obscure the copy certification box.
# -*- coding: utf-8 -*-
from PIL import Image, ImageDraw, ImageFont
WM_TEXT = 'ใช้สำหรับยื่นคำขอฉบับนี้เท่านั้น'
WM_DATE = '13 พฤศจิกายน 2569'
FONT_BOLD = r'C:/Windows/Fonts/leelawdb.ttf'
def make_wm_layer(size_px, angle=18, alpha=170, font_px=72):
f1 = ImageFont.truetype(FONT_BOLD, font_px)
f2 = ImageFont.truetype(FONT_BOLD, int(font_px * 0.6))
probe = ImageDraw.Draw(Image.new('RGBA', (10, 10)))
w1 = int(probe.textlength(WM_TEXT, font=f1))
w2 = int(probe.textlength(WM_DATE, font=f2))
strip = Image.new('RGBA', (w1 + 40, int(font_px * 2.6)), (0, 0, 0, 0))
sd = ImageDraw.Draw(strip)
col = (60, 60, 60, alpha) # เทาโปร่ง อ่านออกแต่ไม่บังรูป
draw_thai_pil(sd, (20, int(font_px * 0.25)), WM_TEXT, f1, col)
draw_thai_pil(sd, (20 + (w1 - w2) // 2, int(font_px * 1.55)), WM_DATE, f2, col)
strip = strip.rotate(angle, expand=True, resample=Image.BICUBIC)
layer = Image.new('RGBA', size_px, (0, 0, 0, 0))
layer.paste(strip, ((size_px[0] - strip.width) // 2,
(size_px[1] - strip.height) // 2), strip)
return layer
# วางทับเฉพาะบริเวณบัตร เผื่อขอบออกไปด้านละ 130 จุดภาพ
wm = make_wm_layer((CARD_PX[0] + 260, CARD_PX[1] + 260))
page.alpha_composite(wm, (cx - 130, cy - 130))
Below the card is the copy certification box, which I draw as three lines: the certification statement, a signature line, and the name in parentheses, all centered under the card.
def certify_block(draw, x_center, y, name, font_path, size=44):
f = ImageFont.truetype(font_path, size)
lines = ['รับรองสำเนาถูกต้อง',
'.' * 55,
'( ' + name + ' )']
for i, line in enumerate(lines):
w = draw.textlength(line, font=f)
draw_thai_pil(draw, (x_center - w / 2, y + i * 90), line, f, (40, 40, 40, 255))
d = ImageDraw.Draw(page)
certify_block(d, A4_PX[0] // 2, cy + CARD_PX[1] + 260,
'นายสมชาย ตัวอย่าง', r'C:/Windows/Fonts/leelawad.ttf')
For attached certificates bearing an agency seal and a printed verification code, I move the certification box into the empty space at the bottom right instead. If a signature covers the seal or verification code, the official may be unable to scan it for verification and reject the entire submission.
Check the work by rendering an image, not opening it in a reader
This is a habit I picked up after having documents rejected: once the file has been created, have the script immediately render the first page as an image, then open that image instead of opening the document in a reader.
- Once the script finishes saving the document, reopen the file it just wrote instead of using the copy still in memory.
- Render the first page at a scale factor of 1.5 and save the image to the review folder.
- Inspect the image visually against the blank form printed at the start, checking that all digits are in their boxes, tone marks do not collide, and no field is blank when it should not be.
- Print one physical copy before submission, only on the first run for each form type, to measure the ID copy with a ruler.
- Once the first run passes, checking the rendered image is enough for subsequent runs.
# ตรวจงาน: อ่านไฟล์ที่เพิ่งเขียน แล้วเรนเดอร์ออกมาเป็นรูป
import fitz
d2 = fitz.open('form_filled.pdf')
pix = d2[0].get_pixmap(matrix=fitz.Matrix(1.5, 1.5))
pix.save('verify/form_filled.png')
print('ตรวจที่ verify/form_filled.png')
The reason for reopening the file instead of using what is still in memory is that I want to check what was actually saved to disk, not what I think I saved. In one case I encountered, saving failed because the file was still open in a reader, but the script reported success, and I ended up submitting the old file once.
Personal data needs attention from the first line
A working folder like this contains ID photos, house registration photos, signatures with their backgrounds removed, and completed documents. Together, they really are enough to impersonate someone. I protect them in four ways. First, this folder stays outside any area synced to the cloud. Second, it must never go into version control; add it to the ignore list from day one. Third, all personal data values live in one separate file, not mixed into the scripts. Fourth, delete the files when the job is done, rather than keeping them just in case.
The third point is more useful than it might seem. While writing this article, I simply replaced the variable values file with sample data and reran the script, producing images for the explanation without a single piece of real data in them. If those values had been embedded in the script, I would have had to remove them line by line and risk leaving something behind.
# ของที่ต้องมีก่อนเริ่ม
pip install pymupdf pillow reportlab
# รันแล้วดูผลที่รูปตรวจงาน ไม่ใช่เปิดไฟล์เอกสาร
python fill_form.py && python make_card_copy.py
Summary
The difficulty in scripting government forms is not the library itself but the details nobody puts in the documentation: fields that cannot handle Thai, subdivided fields whose box counts have to match, tone marks that need manual positioning, and a flattening sequence that fails silently if I get it wrong.
I came away with three lessons. First, always list the field names before doing anything else. Those twenty minutes save hours of guessing. Second, flatten the document first, then draw the Thai text on top, and remember to retrieve the page again after flattening. Third, every ID copy must have a watermark stating its purpose and date, regardless of who receives it.
Use another library if that suits you. The same ideas about step order and raising tone marks apply; only the function names change.
P.S. One thing I have not finished is reading coordinates from a configuration file instead of embedding the numbers in the script. Right now, when a new version of a form moves things by two or three millimeters, I have to track down and edit the numbers in the code, which is not how it should work. I will come back and fix that later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first