Skip to content

File DPI Metadata Design

Design notes for writing correct horizontal and vertical DPI (PPI) metadata into BMP / PNG / JPG / TIFF files produced by BN Tech Virtual Scanner.

1. Requirement

Files produced by the virtual scanner (whether written by File Transfer or saved by the application after Native Transfer) must show the correct horizontal and vertical DPI in Windows Explorer's Details tab and in third-party image applications, matching the value selected in the settings UI.

Main requirements:

  • When the user picks 150 / 200 / 300 / 600 DPI in the settings UI (or the application sets a different value via ICAP_XRESOLUTION / ICAP_YRESOLUTION), the output file must record exactly that DPI.
  • Cover every supported output format: BMP, PNG, JPG, TIFF.
  • Horizontal and vertical DPI must be written independently (the UI exposes a single value, but ScannerSettings.x_resolution / y_resolution are independent fields).
  • The unit declaration must be unambiguously "per inch"; consumers must not interpret the values as cm or unitless.
  • Windows Explorer's Details tab must show exactly the selected DPI; common consumers (XnView, IrfanView, Photoshop, NAPS2) must agree.
  • Failures must not corrupt the file: either the patch succeeds and produces a valid file, or the original FreeImage_Save output is preserved.

Non-functional:

  • DPI patching must not alter pixel data; only metadata fields or chunks.
  • No additional dependencies (no libpng / libjpeg / libtiff); handcraft byte-level writers.
  • Patching must be tolerant of fields FreeImage already wrote correctly; a re-patch over correct values must yield identical output.

2. Domain knowledge

2.1 How each format expresses DPI

Format Field / chunk Unit Type Notes
BMP BITMAPINFOHEADER.biXPelsPerMeter / biYPelsPerMeter pixels per meter LE int32 Inside the DIB header.
PNG pHYs chunk pixels per meter (unit byte = 1) BE uint32 + 1-byte unit Unit = 0 means undefined.
JPG (JFIF) APP0 (0xFFE0) density fields unit 1 = dpi, 2 = dpcm BE uint16 Older JFIF allows unit = 0.
TIFF IFD tags XResolution (282) / YResolution (283) + ResolutionUnit (296) RATIONAL + SHORT TIFF endianness ResolutionUnit 1 = none, 2 = inch, 3 = cm.

2.2 Unit conversion

  • DPI -> pixels per meter: ppm = dpi * 39.3700787.
  • Windows converts ppm back to DPI for display; round-trip error is typically < 1 DPI.
  • JFIF density values are bare integers; the unit byte selects DPI vs DPC.
  • TIFF stores rationals (numerator / denominator); the project fixes denominator = 100, giving two-decimal precision.

2.3 Actual FreeImage behavior

  • BMP: usually correct.
  • PNG: some FreeImage versions ignore FreeImage_SetDotsPerMeter and emit no pHYs.
  • JPG: JFIF density frequently missing or unit = 0, making Windows fall back to 96 DPI.
  • TIFF: XResolution / YResolution usually present, but ResolutionUnit sometimes 1 (none).

A second-pass patch is therefore needed.

2.4 Endianness

  • PNG: big-endian.
  • JPG JFIF: big-endian for density fields.
  • BMP: little-endian.
  • TIFF: byte order set by II (LE) or MM (BE) in the header.

2.5 PNG pHYs chunk layout

4 bytes  Length        = 9
4 bytes  Type          = "pHYs"
4 bytes  X pixels per unit (BE)
4 bytes  Y pixels per unit (BE)
1 byte   Unit          (0 = unknown, 1 = meter)
4 bytes  CRC32 (over Type + data)

pHYs must appear before the first IDAT; placing it right after IHDR is safest.

2.6 JPG JFIF APP0 layout

2 bytes  Marker     = 0xFF 0xE0
2 bytes  Length     = 16 (BE)
5 bytes  Identifier = "JFIF\0"
2 bytes  Version    = 1.01
1 byte   Unit       (0 = none, 1 = inch, 2 = cm)
2 bytes  X density  (BE)
2 bytes  Y density  (BE)
1 byte   X thumbnail = 0
1 byte   Y thumbnail = 0

Overwrite the existing APP0 if present; otherwise insert a fresh one immediately after SOI (0xFFD8).

2.7 BMP DPI offsets

biXPelsPerMeter is at file offset 38, biYPelsPerMeter at 42 (assuming BITMAPFILEHEADER + BITMAPINFOHEADER).

2.8 TIFF IFD entry

2 bytes  Tag
2 bytes  Type    (5 = RATIONAL, 3 = SHORT)
4 bytes  Count
4 bytes  Value/Offset

XResolution (282) / YResolution (283) are RATIONAL with count = 1; the value/offset points to 8 bytes (numerator + denominator). ResolutionUnit (296) is SHORT with count = 1, stored inline.

2.9 Windows Explorer interpretation

Explorer needs the right unit byte to display DPI:

  • PNG / JFIF: unit must be 1; otherwise it shows 96 DPI.
  • TIFF: ResolutionUnit must be 2 (inch).
  • BMP: always reads ppm and computes DPI.

The unit declaration is more important than the numeric value.

3. Design goals

  • Cover BMP / PNG / JPG / TIFF.
  • No additional third-party dependencies; pure <fstream> byte I/O.
  • DPI source is shared with ICAP_XRESOLUTION / ICAP_YRESOLUTION / ICAP_UNITS and with File / Native Transfer (single ScannerSettings).
  • Always re-patch after FreeImage_Save, regardless of FreeImage's success.
  • Use the "per inch" unit branch in every format.

Non-goals:

  • No full EXIF parser; just write one set of EXIF tags via FreeImage.
  • No multi-page TIFF IFD chains.
  • No metadata other than DPI (color profile, orientation, color space remain out of scope).
  • No BMP V4 / V5 headers (the project only emits BITMAPINFOHEADER).

4. Overall design

VirtualScanner
├── applyDpiMetadata()           // Sets FreeImage internal DPI + EXIF tags
└── patchSavedDpiMetadata(fif, path)
        ├── patchPngDpiMetadata()
        ├── patchJpegDpiMetadata()
        ├── patchBmpDpiMetadata()
        └── patchTiffDpiMetadata()

Two-layer DPI writing:

  1. Before save: applyDpiMetadata() sets FreeImage_SetDotsPerMeterX / Y and writes EXIF XResolution / YResolution / ResolutionUnit so FreeImage outputs the right fields where it can.
  2. After save: patchSavedDpiMetadata(fif, path) re-writes the container-level fields by byte-level patching, guaranteeing the final file is correct even if FreeImage missed them.

Shared byte utilities: little / big-endian readers and writers, PNG crc32Png, dpiToPixelsPerMeter, dpiToJpegDensity, whole-file readFileBytes / writeFileBytes, and makePngPhysChunk / makeJpegJfifApp0Segment constructors.

5. Key decisions and rationale

5.1 Always re-patch after FreeImage_Save

Patchers are idempotent: correct fields stay correct, wrong fields get fixed. Keeps the code path simple and avoids version-detection.

5.2 Hand-written byte-level patchers, no extra libs

No new dependencies, no licensing concerns, no DLL bloat. Specialized code for a single field is easier to maintain than pulling in libpng / libjpeg / libtiff just for DPI.

5.3 "Per inch" unit in every format

PNG ppm is mathematically equivalent to DPI (ppm = dpi * 39.37); JFIF and TIFF have explicit inch units. Setting them consistently matches Windows Explorer's DPI display logic and aligns with ICAP_UNITS = TWUN_INCHES.

5.4 BMP: write directly to offsets 38 / 42

The project only emits BITMAPINFOHEADER, so the offsets are fixed. Avoids parsing the DIB header.

5.5 PNG: replace existing pHYs or insert after IHDR

pHYs must precede IDAT; right after IHDR is always legal and easy to locate. Self-implemented CRC32 ensures any PNG decoder accepts the chunk.

5.6 JPG: overwrite JFIF APP0 if present, otherwise insert after SOI

Most FreeImage JPGs already carry JFIF APP0 (just with the wrong unit/density). When missing, inserting a 18-byte standard APP0 after 0xFFD8 is the safest position.

5.7 TIFF: first IFD only, overwrite existing entries

The project produces single-page TIFFs. FreeImage reliably writes the three entries; the patcher only needs to overwrite them. Adding entries would require shifting the IFD and patching value/offset pointers, which is far more invasive.

5.8 applyDpiMetadata also writes EXIF tags

Some consumers read EXIF rather than container-specific fields. Writing EXIF gives a fallback even if container-level patching fails.

6. Component changes

6.1 virtual_scanner.h

New private methods:

  • void applyDpiMetadata();
  • void patchSavedDpiMetadata(FREE_IMAGE_FORMAT fif, const std::string& path);

6.2 virtual_scanner.cpp (anonymous namespace)

Byte helpers (readLittleEndian16/32, readBigEndian32, writeLittleEndian16/32, writeBigEndian16/32), crc32Png, dpiToPixelsPerMeter, dpiToJpegDensity, readFileBytes, writeFileBytes, makePngPhysChunk, makeJpegJfifApp0Segment.

Four patchers:

  • patchPngDpiMetadata: scan chunks, replace pHYs if present, otherwise insert after IHDR.
  • patchJpegDpiMetadata: scan markers, overwrite JFIF APP0 density + unit, otherwise insert APP0 after SOI.
  • patchBmpDpiMetadata: validate magic and DIB header size, then write LE int32s at offsets 38 / 42.
  • patchTiffDpiMetadata: parse header + first IFD, overwrite tags 282 / 283 / 296 in place.

6.3 VirtualScanner::applyDpiMetadata()

  • FreeImage_SetDotsPerMeterX / Y for the FreeImage internal fields.
  • Create EXIF tags: XResolution (0x011A) and YResolution (0x011B) (FIDT_RATIONAL, denominator 100), ResolutionUnit (0x0128) (FIDT_SHORT, value 2).
  • Attach via FreeImage_SetMetadata(FIMD_EXIF_MAIN, dib_, key, tag).

6.4 VirtualScanner::patchSavedDpiMetadata()

  • Read DPI from settings_.x_resolution / y_resolution (fallback 300).
  • Dispatch on FREE_IMAGE_FORMAT to the matching patcher.

6.5 VirtualScanner::saveImageToFile() / saveImageToPath()

  • Call applyDpiMetadata() before FreeImage_Save.
  • Call patchSavedDpiMetadata() after a successful save.
  • Patcher failure does not fail the overall save; the file remains valid.

6.6 twain_data_source.cpp

  • File / Native Transfer share ScannerSettings, so the DS layer needs no additional DPI logic.
  • getImageInfo() returns TW_IMAGEINFO.XResolution / YResolution from the same ScannerSettings; allocAndFillDibHeader() writes the same DPI into biXPelsPerMeter / biYPelsPerMeter, so Native-Transfer DIBs saved by the application also carry the correct DPI.

7. Typical flows

7.1 File Transfer saves a PNG

1. settings_.x_resolution = 600, y_resolution = 600
2. saveImageToFile():
     applyDpiMetadata() -> FreeImage_SetDotsPerMeter(dib_, 23622, 23622)
                       -> EXIF XResolution = 60000/100, etc.
     FreeImage_Save(FIF_PNG, dib_, "D:\\scans\\a.png", 0)
     patchSavedDpiMetadata(FIF_PNG, "D:\\scans\\a.png")
       -> patchPngDpiMetadata: replace existing pHYs with ppm=23622, unit=1
3. Explorer: 600 dpi / 600 dpi.

7.2 File Transfer saves a JPG (FreeImage misses JFIF density)

1. settings_.x_resolution = 300, y_resolution = 300
2. FreeImage's JPG has APP0 unit = 0
3. patchJpegDpiMetadata("D:\\scans\\a.jpg", 300, 300)
   -> finds APP0 -> writes unit = 1, x = y = 300
4. Explorer: 300 dpi / 300 dpi.

7.3 Native Transfer, application saves a BMP

1. allocAndFillDibHeader writes biXPelsPerMeter = biYPelsPerMeter = round(150 * 39.37)
2. App locks the DIB, writes the BMP itself, DIB header is preserved
3. Explorer: 150 dpi / 150 dpi (no patcher needed; DIB already correct).

8. Limitations

  • Patcher failure returns false but is not retried; the saved file remains, possibly with wrong DPI.
  • BMP patcher assumes BITMAPINFOHEADER; would need updates for V4 / V5 headers.
  • TIFF patcher handles only the first IFD; multi-page TIFFs would need IFD chain traversal.
  • TIFF patcher only overwrites existing entries; missing entries cause it to skip.
  • TIFF rational numerator multiplies by 100; extreme high DPI could overflow uint32.
  • PNG patcher requires IHDR to be present; malformed files are left untouched.
  • JPG patcher does not handle corrupt files lacking SOI or with unexpected marker sequences.
  • File I/O is whole-file in memory; very large files consume proportional memory.
  • Unit is hard-coded to "per inch"; there is no UI or capability to switch to centimeters.
  • EXIF tags are written only to FIMD_EXIF_MAIN; consumers that read EXIF IFD0 might miss them.

9. Next steps

  • Add unit tests for the four patchers (fixed input bytes + DPI, byte-compare output).
  • Log patcher failures (OutputDebugStringA) and surface a warning in the settings UI on failure.
  • Support BMP V4 / V5 headers if the project ever needs ICC profile output.
  • Multi-page TIFF support: traverse next IFD and patch each IFD's entries.
  • Insert TIFF entries / JPG APP0 when they are entirely missing.
  • Add TWUN_CENTIMETERS support end-to-end (capability + patcher unit fields).
  • Also write equivalent EXIF IFD0 fields for wider tool compatibility.
  • Atomic file writes (.tmp + rename) so a crash mid-patch never corrupts the saved file.
  • Add a post-save round-trip check: re-read DPI and compare against the setting as a smoke test.
  • Evaluate future PDF output: PDF expresses DPI through /UserUnit or the image stream's Width / Height + Decode, which would require a different patcher strategy.