photos: prove the round trip and document the wire shape
Both captured shapes (log/unhandled, 34 KB and 338 KB) already parse
and round-trip byte for byte, so nothing in lib changes — the tests
are the proof. The captures stay unpromoted because they are possibly
real images; test/photo_card.rb rebuilds their shapes with synthetic
payloads.

Assisted-by: GLM-5.3 via pi
change xrkxmzpovsyttxpulqqontukpkklylqk
commit 2d4bb8d73d27c9cd42fcfa125f5cee1dd5ce7e7e
author Alpha Chen <alpha@kejadlen.dev>
date
parent qkyusnuk
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
index 6b3d70e..b0a8dc3 100644
--- a/docs/macos-contacts.md
+++ b/docs/macos-contacts.md
@@ -187,6 +187,52 @@ The practical consequence is that any comparison between a card the server
 sent and the card that comes back has to be semantic. Comparing bytes
 reports every untouched property as modified. Verified 2026-08-24.
 
+## Profile pictures
+
+A contact with a picture carries it in the card, inline — `PHOTO` with
+`ENCODING=b;TYPE=JPEG` and a base64 payload (RFC 2426 section 3.1.4's
+second form), never a URI. A memoji card runs 34 KB, a real photo 338 KB
+— 323,900 octets of base64 for a 243 KB JPEG — because the client does
+not downscale before sending. A bare card is ~500 bytes, so photos are
+the sizing case; a memoji is not.
+
+The picture trio as it arrives:
+
+```
+X-IMAGEHASH:RKZaNYCmuYERD3AfbLtrIw==
+X-IMAGETYPE:PHOTO
+PHOTO;X-ABCROP-RECTANGLE=ABClipRect_1&38&0&769&768&RKZaNYCmuYERD3AfbLtrIw==;ENCODING=b;TYPE=JPEG:
+ <76 octets of base64>
+ ...
+```
+
+- `X-IMAGETYPE:PHOTO` sits directly above `PHOTO`, and the token
+  `X-ABCROP-RECTANGLE` ends with is `X-IMAGEHASH`'s value.
+- The crop rectangle can be a real crop — the photo's origin is (38,0),
+  not the whole image.
+- A memoji adds `VND-63-MEMOJI-DETAILS` — a base64 binary plist —
+  between the crop and the encoding, which alone runs the parameter
+  section to 1,683 octets on one physical line.
+
+RFC 2426 section 2.6's 75-octet fold is a SHOULD the client meets only
+after the colon: the payload is folded — a space, then 76 octets a
+continuation, 4,378 continuation lines for the photo — while the
+parameter section is not folded at all. A parser that assumes a
+folded-line ceiling, or that splits parameters before unfolding, breaks
+on the memoji card.
+
+Where the bytes land: warm sync asks only for etags, so polling stays
+~4 KB whatever the book holds. A cold sync — the etag listing plus one
+multiget with a bare `<C:address-data/>`, no property filter — ships
+every card whole, so one photo card makes that one response ~340 KB.
+Serving one contact, by GET or multiget, is the card's own size.
+Measured 2026-09-04 against a 13-contact book with one photo card:
+the multiget builds in ~2 ms and the PUT lands in ~17 ms, so the
+response size is the whole of the cost — the bytes are the floor, since
+the client needs the picture. Captured 2026-08-25, macOS 26.5.1
+(AddressBookCore/2732.600.11); the shapes are built synthetically in
+test/photo_card.rb, the captures being possibly real images.
+
 ## Birthdays without a year
 
 A birthday entered as a month and day carries Apple's own parameter, using
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 8500f86..6b6914d 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -167,10 +167,13 @@ module ProTacts
       # The recorded macOS session folds nothing: it sent a 443-octet
       # X-ADDRESSING-GRAMMAR line and an 81-octet ADR unbroken, both
       # past the 75-octet limit, with no continuation in the card
-      # (test/fixtures/macos-exchange/10-put-contact-edit). That is one
-      # capture of one card, and the PHOTO bodies that would settle it
-      # went unpromoted, so this stays general rather than becoming
-      # another macOS assumption.
+      # (test/fixtures/macos-exchange/10-put-contact-edit). The PHOTO
+      # bodies that would settle the other half stayed unpromoted
+      # (test/photo_card.rb builds their shapes) and they settle it
+      # both ways: a picture's payload arrives folded, a space then 76
+      # octets a continuation, while its parameter section arrives not
+      # folded at all — 1,683 octets on one line for a memoji. A whole-
+      # line unfold reads both without caring which it was given.
       #: (String line) -> String
       def self.unfold(line)
         line.gsub(FOLD, "")
diff --git a/test/fixtures/macos-exchange/README.md b/test/fixtures/macos-exchange/README.md
index bec4442..d727c98 100644
--- a/test/fixtures/macos-exchange/README.md
+++ b/test/fixtures/macos-exchange/README.md
@@ -49,3 +49,6 @@ The capture also lost the body's final newline — 835 bytes declared,
 834 recorded — so the promoted card ends at `END:VCARD`. Captures with
 `PHOTO` bodies (34 KB and 338 KB cards) were left unpromoted: possibly
 real images, and the same code path as the 834-byte card that was.
+Their wire shapes — the unfolded parameter section, the payload folded
+at 76 octets a continuation — are built synthetically in
+`test/photo_card.rb` and documented in `docs/macos-contacts.md`.
diff --git a/test/photo_card.rb b/test/photo_card.rb
new file mode 100644
index 0000000..4502f3d
--- /dev/null
+++ b/test/photo_card.rb
@@ -0,0 +1,124 @@
+require "base64"
+
+require "pro_tacts/vcard"
+
+# Builds the cards macOS Contacts sends for a contact with a picture,
+# in the exact wire shapes of the two captures in log/unhandled
+# (put-...-1a6555e1 for the memoji, put-...-ae05fe03 for the photo).
+# The captures stay unpromoted — possibly real images, see
+# test/fixtures/macos-exchange/README.md — so the shapes live here as
+# structure with synthetic payloads standing in for the pictures.
+#
+# What the captures carry, and this reproduces:
+#
+# - PHOTO's parameter section is one physical line ending at the
+#   colon, unfolded however long it is: 99 octets for a photo, 1,685
+#   for a memoji, whose VND-63-MEMOJI-DETAILS parameter (a base64
+#   binary plist) is most of those octets. RFC 2426 section 2.6's
+#   75-octet limit is a SHOULD the client meets only after the colon.
+# - The base64 payload is inline (ENCODING=b, TYPE=JPEG — never a
+#   URI), folded one break below the parameters: every continuation is
+#   a single space then 76 octets of payload.
+# - X-IMAGETYPE:PHOTO sits directly above PHOTO, and the token
+#   X-ABCROP-RECTANGLE ends with is X-IMAGEHASH's value.
+#
+# The cards land within a percent of the captures' own sizes; the two
+# dimensions that define the shapes — the parameter lines' 97 and
+# 1,683 octets — match exactly.
+module PhotoCard
+  # The token naming the image in both X-IMAGEHASH and the crop
+  # rectangle's tail, matching the captures' shape: 24 octets ending
+  # "==", the encoding of a 16-byte hash.
+  IMAGE_HASH = Base64.strict_encode64("pro-tacts-image!") #: String
+
+  # A photo's parameters (capture ae05fe03): a real crop — origin
+  # (38,0), not the whole image — then the encoding pair.
+  PHOTO_PARAMETERS = [
+    "X-ABCROP-RECTANGLE=ABClipRect_1&38&0&769&768&#{IMAGE_HASH}",
+    "ENCODING=b",
+    "TYPE=JPEG",
+  ] #: Array[String]
+
+  # A memoji's parameters (capture 1a6555e1): the crop is the whole
+  # image, and VND-63-MEMOJI-DETAILS sits between it and the encoding
+  # pair. The capture's whole parameter section runs 1,683 octets on
+  # one physical line, so the value is sized to land this on that
+  # number — past 75 by an order of magnitude, which is the dimension
+  # that matters, at the capture's own mark. The capture's own value
+  # is a base64 binary plist; synthetic base64 stands in, since
+  # nothing on the server decodes it.
+  MEMOJI_LINE = 1_683 #: Integer
+  MEMOJI_CROP = "X-ABCROP-RECTANGLE=ABClipRect_1&0&0&420&420&#{IMAGE_HASH}" #: String
+  MEMOJI_DETAILS_BYTES =
+    (MEMOJI_LINE - "PHOTO;#{MEMOJI_CROP};VND-63-MEMOJI-DETAILS=".length - ";ENCODING=b;TYPE=JPEG:".length)
+      .then { it - it % 4 } # base64 characters come four to three bytes
+      .then { it / 4 * 3 } #: Integer
+  MEMOJI_PARAMETERS = [
+    MEMOJI_CROP,
+    "VND-63-MEMOJI-DETAILS=#{Base64.strict_encode64("m" * MEMOJI_DETAILS_BYTES)}",
+    "ENCODING=b",
+    "TYPE=JPEG",
+  ] #: Array[String]
+
+  # The photo property as the client folds it, per the module comment:
+  # parameters unbroken on one line, payload a space then 76 octets a
+  # continuation. Deliberately not VCard.fold, which honors the
+  # 75-octet limit the client ignores here — this half builds what
+  # arrives, and the writer's half is VCard's.
+  #: (Array[String] parameters, String image_bytes) -> String
+  def self.property(parameters, image_bytes)
+    base64 = Base64.strict_encode64(image_bytes)
+    continuations = base64.scan(/.{1,76}/).map { " #{it}" }
+    "PHOTO;#{parameters.join(';')}:\r\n#{continuations.join("\r\n")}\r\n"
+  end
+
+  # Synthetic image bytes: never decoded — a card's payload is stored
+  # and served as the base64 text it arrives as — so a repeating
+  # pattern stands in, deterministic so a fixture's etag does not vary
+  # per run.
+  #: (Integer bytes) -> String
+  def self.image(bytes)
+    ("\xFF\xD8\xFF\xE0" + "synthetic photo payload" * (bytes / 22 + 1)).byteslice(0, bytes)
+  end
+
+  # A card with a real photo attached: the 338 KB capture's shape. A
+  # 242,925-byte image is the capture's size, because the client does
+  # not downscale before sending — photos are the sizing case.
+  #: (String id, ?bytes: Integer, ?extra: Array[String]) -> String
+  def self.photo(id, bytes: 242_925, extra: [])
+    card(id, property(PHOTO_PARAMETERS, image(bytes)), extra:)
+  end
+
+  # A card with a memoji attached: the 34 KB capture's shape, whose
+  # parameter section runs 1,685 octets unfolded on one line.
+  #: (String id, ?extra: Array[String]) -> String
+  def self.memoji(id, extra: [])
+    card(id, property(MEMOJI_PARAMETERS, image(23 * 1024)), extra:)
+  end
+
+  # The card around the picture trio, the capture's own lines: the
+  # trio sits between NOTE and REV, and `extra` lands above it, which
+  # is where the captures carry their BDAY.
+  #: (String id, String photo_property, extra: Array[String]) -> String
+  def self.card(id, photo_property, extra: [])
+    [
+      "BEGIN:VCARD",
+      "VERSION:3.0",
+      "PRODID:-//Apple Inc.//macOS 26.5.1//EN",
+      "N:Lovelace;Ada;;;",
+      "FN:Ada Lovelace",
+      "EMAIL;type=INTERNET;type=pref:ada@example.com",
+      "TEL;type=CELL;type=VOICE;type=pref:+1-555-0100",
+      "X-ADDRESSING-GRAMMAR;type=pref:#{Base64.strict_encode64("addressing-grammar")}",
+      "ADR;type=HOME;type=pref:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+      "NOTE:Test",
+      *extra,
+      "X-IMAGEHASH:#{IMAGE_HASH}",
+      "X-IMAGETYPE:PHOTO",
+      photo_property,
+      "REV:2026-08-25T03:15:06Z",
+      "UID:#{id}",
+      "END:VCARD",
+    ].join("\r\n") + "\r\n"
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index a3fb877..6a6f562 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1,4 +1,5 @@
 require_relative "../test_helper"
+require_relative "../photo_card"
 
 require "pathname"
 require "tmpdir"
@@ -12,6 +13,9 @@ class StoreTest < Minitest::Test
   include SentryMessages
 
   AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+  # The no-year birthday line as macOS writes it — the shape the
+  # captured picture cards carry above their PHOTO trio.
+  APPLE_NO_YEAR = "BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01" #: String
   # UTC ISO 8601 to the millisecond, which is what SQLite is asked for.
   TIMESTAMP = /\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\z/
   ZED = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zed\r\nUID:znorth\r\nEND:VCARD\r\n"
@@ -61,6 +65,43 @@ class StoreTest < Minitest::Test
     with_store { assert_nil it.contact("nobody") }
   end
 
+  ## Pictures
+
+  # The sizing case, byte for byte: a photo card is 338 KB — most of
+  # it the PHOTO property's base64 folds — and the store's verbatim
+  # design means every fold comes back out exactly as it arrived. The
+  # memoji shape exercises the one physical line that runs 1,683
+  # octets.
+  def test_a_photo_card_round_trips_byte_for_byte
+    with_store({}) do |store|
+      store.put("ada", PhotoCard.photo("ada"))
+
+      assert_equal PhotoCard.photo("ada"), store.contact("ada").vcard.to_s
+    end
+  end
+
+  def test_a_memoji_card_round_trips_byte_for_byte
+    with_store({}) do |store|
+      store.put("ada", PhotoCard.memoji("ada"))
+
+      assert_equal PhotoCard.memoji("ada"), store.contact("ada").vcard.to_s
+    end
+  end
+
+  # The captured writes carry their BDAY above the picture trio, so
+  # the birthday's move to the model and back is the one difference a
+  # picture card sees: the PHOTO line, folds and all, is untouched.
+  def test_a_photo_card_moves_only_its_birthday
+    submitted = PhotoCard.photo("ada", extra: [APPLE_NO_YEAR])
+    expected = PhotoCard.photo("ada").sub("END:VCARD\r\n", "#{APPLE_NO_YEAR}\r\nEND:VCARD\r\n")
+
+    with_store({}) do |store|
+      store.put("ada", submitted)
+
+      assert_equal expected, store.contact("ada").vcard.to_s
+    end
+  end
+
   ## UIDs
 
   # The read behind the no-uid-conflict precondition (RFC 6352 section
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 7bc5624..af5ffba 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -1,5 +1,7 @@
 
 require_relative "../test_helper"
+require_relative "../photo_card"
+
 require "digest"
 require "fileutils"
 require "pathname"
@@ -515,6 +517,66 @@ class WebTest < Minitest::Test
     end
   end
 
+  ## Pictures
+
+  # The sizing case on the wire: a 338 KB PUT — the client does not
+  # downscale before sending — stores octet for octet, so the answer
+  # carries the strong etag of exactly those bytes (RFC 6352 section
+  # 6.3.2.3) and the GET hands them back unchanged.
+  def test_put_of_a_photo_card_round_trips_octet_identically
+    card = PhotoCard.photo("new")
+
+    with_contacts({}) do
+      put_request "new", card, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      assert_equal 201, last_response.status
+      assert_equal %("#{Digest::SHA256.hexdigest(card)}"), last_response["ETag"]
+
+      get "/dav/addressbook/new.vcf"
+
+      assert_equal card, last_response.body
+      assert_equal %("#{Digest::SHA256.hexdigest(card)}"), last_response["ETag"]
+    end
+  end
+
+  # A bare <C:address-data/> with no property filter is what macOS
+  # asks a multiget with (fixture 07-report-multiget), so a picture
+  # ships whole inside it — where the sizing case's cost lands.
+  def test_a_multiget_ships_a_photo_card_whole
+    card = PhotoCard.photo("new")
+
+    with_contacts({}) do
+      put_request "new", card, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      request "/dav/addressbook/", method: "REPORT", input: multiget("new")
+
+      assert_equal 207, last_response.status
+      served = last_response.body[%r{<card:address-data>(.*)</card:address-data>}m, 1]
+      assert_equal card.chomp, xml_unescape(served)
+    end
+  end
+
+  # The captured write carries its BDAY above the picture trio, so the
+  # birthday's move to the model and back is the one difference the
+  # card sees: the PUT goes without the strong etag — the client
+  # refetches — and the refetch carries the picture and the birthday
+  # both.
+  def test_put_of_a_photo_card_with_a_birthday_moves_only_the_birthday
+    submitted = PhotoCard.photo("new", extra: ["BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01"])
+    expected = PhotoCard.photo("new").sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01\r\nEND:VCARD\r\n")
+
+    with_contacts({}) do
+      put_request "new", submitted, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      assert_equal 201, last_response.status
+      assert_nil last_response["ETag"]
+
+      get "/dav/addressbook/new.vcf"
+
+      assert_equal expected, last_response.body
+    end
+  end
+
   # A body arrives flagged ASCII-8BIT — Rack's rule for request input —
   # and becomes UTF-8 where the route meets the wire, so a card with
   # non-ASCII in it stores and serves as the UTF-8 it is.
@@ -550,6 +612,12 @@ class WebTest < Minitest::Test
     "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\nUID:#{id}\r\nEND:VCARD\r\n"
   end
 
+  # The route's xml_escape, reversed, for reading a card back out of
+  # a multiget body and comparing it with what was PUT.
+  def xml_unescape(text)
+    text.gsub("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&")
+  end
+
   # Hands the app a throwaway store so the multi-contact routes can be
   # exercised without touching the exchange fixture data, and yields it
   # so a test can change a card mid-request-sequence. Only the store is
diff --git a/test/pro_tacts/vcard/test_parser.rb b/test/pro_tacts/vcard/test_parser.rb
index 5658828..31ace17 100644
--- a/test/pro_tacts/vcard/test_parser.rb
+++ b/test/pro_tacts/vcard/test_parser.rb
@@ -1,5 +1,7 @@
 require_relative "../../test_helper"
+require_relative "../../photo_card"
 
+require "base64"
 require "hegel"
 
 require "pro_tacts/vcard/parser"
@@ -159,6 +161,57 @@ class VCardParserTest < Minitest::Test
     assert_equal "abcd", properties(card("NOTE:ab", "\tcd")).first.value
   end
 
+  ## Pictures
+
+  # The two shapes a picture arrives in, built as captured
+  # (test/photo_card.rb, from log/unhandled): the parameter section on
+  # one physical line however long, the base64 payload folded one
+  # break below it. The parser reads both without a length ceiling
+  # because the line split comes before the token scan — nothing here
+  # has to know how long any line is.
+  def test_a_photos_parameters_and_payload_read_whole
+    image = PhotoCard.image(600)
+    photo = PhotoCard.property(PhotoCard::PHOTO_PARAMETERS, image)
+    property = properties(card(photo)).first
+
+    assert_equal PhotoCard::PHOTO_PARAMETERS.map { it.split("=", 2) }, property.parameters
+    assert_equal Base64.strict_encode64(image), property.value
+  end
+
+  # The memoji shape adds VND-63-MEMOJI-DETAILS, whose base64 plist
+  # runs the parameter section to 1,683 octets on one line — past the
+  # 75-octet fold by an order of magnitude, with the payload folded
+  # correctly beneath it.
+  def test_a_memojis_unfolded_parameter_line_reads
+    image = PhotoCard.image(600)
+    memoji = PhotoCard.property(PhotoCard::MEMOJI_PARAMETERS, image)
+    property = properties(card(memoji)).first
+
+    assert_equal PhotoCard::MEMOJI_PARAMETERS.map { it.split("=", 2) }, property.parameters
+    assert_equal Base64.strict_encode64(image), property.value
+  end
+
+  # Any image and any memoji details, folded the client's way, read
+  # back whole — the parser's correctness rests on the fold shape,
+  # not on the two captured sizes.
+  def test_a_photo_folded_like_the_client_reads_back_whole
+    Hegel.test do |tc|
+      image = tc.draw(text(max_size: 4_000))
+      details = tc.draw(text(max_size: 2_000))
+      parameters = [
+        PhotoCard::MEMOJI_CROP,
+        "VND-63-MEMOJI-DETAILS=#{Base64.strict_encode64(details)}",
+        "ENCODING=b",
+        "TYPE=JPEG",
+      ]
+
+      property = properties(card(PhotoCard.property(parameters, image))).first
+
+      raise "payload did not survive" unless property.value == Base64.strict_encode64(image)
+      raise "parameters did not survive" unless property.parameters == parameters.map { it.split("=", 2) }
+    end
+  end
+
   ## Lines
 
   # lines pairs each property with the exact bytes it came from — the