vcard: a run of line breaks ends one content line
`NOTE:a\r\r\n` raised BrokenAssumption with the "second content line"
message, although nothing followed the first but line breaks. The
parse now skips line breaks after the content line as it does before
it, and only real content after a bare CR is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit ec38c91613b9956fbe8a26e7eb64bfb22933dc89
author Claude <noreply@anthropic.com>
date
parent 86ee08ce
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index c931db8..e87faa6 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -301,6 +301,9 @@ module ProTacts
         return nil if @scanner.eos?
 
         property = content_line
+        # Line breaks with nothing between them are no second content
+        # line, after the first as before it: `NOTE:a\r\r\n` carries one.
+        nil while @scanner.skip(LINE_BREAK)
         return property if @scanner.eos?
 
         raise BrokenAssumption, "a bare CR packed a second content line into one line #{here}"
diff --git a/test/pro_tacts/vcard/test_parser.rb b/test/pro_tacts/vcard/test_parser.rb
index a34f7e3..abad1dc 100644
--- a/test/pro_tacts/vcard/test_parser.rb
+++ b/test/pro_tacts/vcard/test_parser.rb
@@ -246,6 +246,21 @@ class VCardParserTest < Minitest::Test
     assert_equal %w[FN], properties("FN:A\r").map { it.name }
   end
 
+  # Line breaks with nothing between them pack no second content line,
+  # whatever mix of CR, LF and CRLF ends a line: the line reads as its
+  # one property, and nothing reports a break (`NOTE:a\r\r\n` was the
+  # case this shrank to).
+  def test_a_run_of_line_breaks_ends_one_content_line
+    Hegel.test(test_cases: 1_000) do |tc|
+      value = tc.draw(from_regex("[^\\r\\n]{0,10}", fullmatch: true))
+      breaks = tc.draw(arrays(sampled_from(["\r", "\n", "\r\n"]), min_size: 1, max_size: 4)).join
+      walked = lines("NOTE:#{value}#{breaks}")
+
+      assert_equal [false], walked.map(&:broke_assumption?).uniq
+      assert_equal [value], walked.filter_map(&:property).map(&:value)
+    end
+  end
+
   # BrokenAssumption is a ParseError: it reads no differently from a
   # line that merely will not read, and the class exists so Store can
   # report the break, not so anything can branch on it.