From e740df4cd2a72389a2649618b789643f64150dbf Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Tue, 28 Jul 2026 08:16:19 +1000 Subject: [PATCH 1/2] Encrypt the byte order mark along with the text it marks A UTF-16BE text string begins with a byte order mark, and that mark is part of the value of the string rather than part of its syntax, so an encrypted document has to encrypt it with the text. PdfSharpCore spelled it out in front of the ciphertext instead, writing where the FEFF was plain and only what followed was encrypted. A reader decrypts the whole string, so it took the mark for two bytes of text, and with the keystream two bytes out of step from there on the rest of the string came out as noise. That is what makes the title and the author of a protected document unreadable in Acrobat and in Firefox, reported in issue #460. Only Title, Author, Subject, Keywords and Creator are affected. PdfDocumentInformation writes those with PdfStringEncoding.Unicode, while Producer is set through the two argument SetString and goes out as a plain literal, which is why the round trip test that asserts on Producer never saw this. The reader made the same mistake in reverse. It strips the mark in the lexer, before anything has been decrypted, so a string encrypted the way the specification asks for it arrives as bytes that no longer say what they are, and a document from any other producer reads back as noise. Nothing showed, because a reader that shares the writer's mistake reads the writer's output back perfectly. The two halves are one fault and have to move together: with only the writer corrected, the existing CreateAndReadPasswordProtectedPdf fails on its outline title. The writer now puts the mark into the bytes before they are encrypted, which leaves an unencrypted document byte for byte what it was, since the same mark is written either way. The reader looks for it after decrypting, where it can now be seen, and corrects the encoding of the string to match what it found. PdfStringObject gains the Unicode handling its TODO asked for. Documents written before this still open: their mark is outside the ciphertext, the lexer strips it as it always did, and what is decrypted then carries no mark to find. The tests check what the writer produces against an independent implementation of the standard security handler rather than by reading it back, since reading it back is what hid this. That implementation checks its own key derivation against the /U entry of the document it is given, so a test that cannot agree with the document fails rather than comparing noise to noise. --- .../Security/EncryptedTextStringTests.cs | 130 ++++++++ .../Security/StandardSecurity.cs | 287 ++++++++++++++++++ PdfSharpCore/Pdf.Internal/PdfEncoders.cs | 15 + PdfSharpCore/Pdf/PdfString.cs | 14 +- PdfSharpCore/Pdf/PdfStringObject.cs | 26 +- 5 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 PdfSharpCore.Test/Security/EncryptedTextStringTests.cs create mode 100644 PdfSharpCore.Test/Security/StandardSecurity.cs diff --git a/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs b/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs new file mode 100644 index 00000000..6ae8ea18 --- /dev/null +++ b/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs @@ -0,0 +1,130 @@ +using System.IO; +using System.Text; +using FluentAssertions; +using PdfSharpCore.Pdf; +using PdfSharpCore.Pdf.IO; +using PdfSharpCore.Pdf.Security; +using Xunit; + +namespace PdfSharpCore.Test.Security +{ + /// + /// The byte order mark of a UTF-16BE text string belongs to the value of the string, so an + /// encrypted document has to encrypt it along with the text. Written outside the ciphertext it + /// is decrypted as if it were text, which shifts everything after it and leaves the string + /// unreadable: that is why the document properties of protected documents came out scrambled in + /// Acrobat and Firefox. See https://github.com/ststeiger/PdfSharpCore/issues/460. + /// + /// What the writer produces is checked with rather than by + /// reading it back, because a reader that shares the writer's mistake reads the writer's output + /// back perfectly, which is how this survived the existing round trip test. + /// + public class EncryptedTextStringTests + { + // Characters that cannot be written in a single byte, so the string has to go out as UTF-16BE. + private const string Title = "Tîtlé wíth àccents"; + private const string Author = "Ångström"; + private const string OwnerPassword = "12343"; + + [Theory] + [InlineData(PdfDocumentSecurityLevel.Encrypted40Bit)] + [InlineData(PdfDocumentSecurityLevel.Encrypted128Bit)] + public void ADocumentPropertyOfAnEncryptedDocumentCanBeReadByAConformingReader( + PdfDocumentSecurityLevel level) + { + var document = new StandardSecurity(SaveEncryptedDocument(level)); + + document.DerivedKeyMatchesTheDocument.Should() + .BeTrue("this test can only judge the strings if it agrees with the document about the key"); + document.DecryptInfoString("/Title").Should().Be(Title); + document.DecryptInfoString("/Author").Should().Be(Author); + } + + [Theory] + [InlineData(PdfDocumentSecurityLevel.Encrypted40Bit)] + [InlineData(PdfDocumentSecurityLevel.Encrypted128Bit)] + public void TheByteOrderMarkIsNotWrittenOutsideTheCipherText(PdfDocumentSecurityLevel level) + { + var document = new StandardSecurity(SaveEncryptedDocument(level)); + + // The mark is encrypted with the text now, so it is no longer spelled out in the file. + document.RawInfoString("/Title").Should().NotStartWith("'); + + StandardSecurity.Latin1String(output.ToArray()).Should().Contain(expected.ToString()); + } + + private static byte[] SaveEncryptedDocument(PdfDocumentSecurityLevel level) + { + var document = new PdfDocument(); + document.AddPage(); + document.Info.Title = Title; + document.Info.Author = Author; + + // The settings reported in the issue. + var settings = document.SecuritySettings; + settings.DocumentSecurityLevel = level; + settings.OwnerPassword = OwnerPassword; + settings.UserPassword = ""; + settings.PermitAnnotations = false; + settings.PermitAssembleDocument = false; + settings.PermitExtractContent = false; + settings.PermitFormsFill = false; + settings.PermitFullQualityPrint = true; + settings.PermitModifyDocument = false; + settings.PermitPrint = true; + + using var output = new MemoryStream(); + document.Save(output, false); + return output.ToArray(); + } + } +} diff --git a/PdfSharpCore.Test/Security/StandardSecurity.cs b/PdfSharpCore.Test/Security/StandardSecurity.cs new file mode 100644 index 00000000..9c6cd5d3 --- /dev/null +++ b/PdfSharpCore.Test/Security/StandardSecurity.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace PdfSharpCore.Test.Security +{ + /// + /// An independent implementation of the standard security handler, enough of it to decrypt the + /// strings of a document encrypted with RC4 and an empty user password. Tests that check what + /// PdfSharpCore writes need a reader that does not share its assumptions: a writer and a reader + /// that make the same mistake agree with each other perfectly, which is how the fault in issue + /// 460 survived a round trip test. + /// + /// The key derivation is checked against the /U entry the document itself carries, so a test + /// using this class fails loudly if the derivation is wrong rather than quietly comparing noise. + /// Algorithms 1, 2, 4 and 5 of ISO 32000-1, clause 7.6. + /// + internal sealed class StandardSecurity + { + private readonly string _pdf; + private readonly byte[] _fileKey; + + public StandardSecurity(byte[] document) + { + _pdf = Latin1String(document); + + byte[] id = FromHex(Last(_pdf, @"/ID\s*\[\s*<([0-9A-Fa-f]+)>").Groups[1].Value); + var encrypt = ObjectBody(int.Parse(Last(_pdf, @"/Encrypt\s+(\d+)\s+\d+\s+R").Groups[1].Value)); + + Revision = int.Parse(Regex.Match(encrypt, @"/R\s+(\d+)").Groups[1].Value); + int permissions = int.Parse(Regex.Match(encrypt, @"/P\s+(-?\d+)").Groups[1].Value); + int keyLength = (encrypt.Contains("/Length") + ? int.Parse(Regex.Match(encrypt, @"/Length\s+(\d+)").Groups[1].Value) + : 40) / 8; + + _fileKey = FileKey(StringValue(encrypt, "/O"), permissions, id, Revision, keyLength); + + byte[] expected = UserEntry(_fileKey, id, Revision); + byte[] actual = StringValue(encrypt, "/U"); + DerivedKeyMatchesTheDocument = StartsWith(actual, expected, Revision == 2 ? 32 : 16); + } + + public int Revision { get; } + + /// Whether this class agrees with the document about the file encryption key. + public bool DerivedKeyMatchesTheDocument { get; } + + /// The entry of the /Info dictionary, decrypted and decoded the way a reader decodes it. + public string DecryptInfoString(string key) + { + byte[] plain = Rc4(ObjectKey(_fileKey, InfoObjectNumber, 0), StringValue(InfoDictionary, key)); + if (plain.Length >= 2 && plain[0] == 0xFE && plain[1] == 0xFF) + return Encoding.BigEndianUnicode.GetString(plain, 2, plain.Length - 2); + return Latin1String(plain); + } + + /// The entry of the /Info dictionary exactly as it stands in the file. + public string RawInfoString(string key) + { + var hex = Regex.Match(InfoDictionary, Regex.Escape(key) + @"\s*(<[0-9A-Fa-f]*>)"); + return hex.Success ? hex.Groups[1].Value : null; + } + + /// + /// Rewrites an /Info entry the way PdfSharpCore wrote it before the fix, with the byte order + /// mark spelled out in front of the ciphertext instead of encrypted with it. The result is + /// the same length as what it replaces, so the cross-reference offsets still hold. + /// + public byte[] RewriteAsWrittenBeforeTheFix(string key, string text) + { + byte[] withoutMark = Encoding.BigEndianUnicode.GetBytes(text); + string replacement = ""; + string original = RawInfoString(key); + + if (original == null) + throw new InvalidOperationException($"{key} is not a hexadecimal string in this document."); + if (replacement.Length != original.Length) + throw new InvalidOperationException( + $"{key}: replacement is {replacement.Length} characters and the original {original.Length}; " + + "the offsets in the document would no longer hold."); + + int at = _pdf.IndexOf(original, InfoDictionaryIndex, StringComparison.Ordinal); + string rewritten = _pdf.Substring(0, at) + replacement + _pdf.Substring(at + original.Length); + return Latin1Bytes(rewritten); + } + + private int InfoObjectNumber => int.Parse(Last(_pdf, @"/Info\s+(\d+)\s+\d+\s+R").Groups[1].Value); + private string InfoDictionary => ObjectBody(InfoObjectNumber); + private int InfoDictionaryIndex => ObjectMatch(InfoObjectNumber).Groups[1].Index; + + private Match ObjectMatch(int number) => + Regex.Match(_pdf, @"(? ObjectMatch(number).Groups[1].Value; + + // Algorithm 2, with the empty user password. + private static byte[] FileKey(byte[] owner, int permissions, byte[] id, int revision, int keyLength) + { + var input = new List(Padding); + input.AddRange(owner); + input.AddRange(BitConverter.GetBytes(permissions)); + input.AddRange(id); + + using var md5 = MD5.Create(); + byte[] hash = md5.ComputeHash(input.ToArray()); + if (revision >= 3) + { + for (var i = 0; i < 50; i++) + hash = md5.ComputeHash(Take(hash, keyLength)); + } + return Take(hash, keyLength); + } + + // Algorithm 1: the key an individual object is encrypted with. + private static byte[] ObjectKey(byte[] fileKey, int objectNumber, int generation) + { + var input = new List(fileKey) + { + (byte)objectNumber, (byte)(objectNumber >> 8), (byte)(objectNumber >> 16), + (byte)generation, (byte)(generation >> 8) + }; + using var md5 = MD5.Create(); + return Take(md5.ComputeHash(input.ToArray()), Math.Min(fileKey.Length + 5, 16)); + } + + // Algorithms 4 and 5: the /U entry for an empty user password. + private static byte[] UserEntry(byte[] fileKey, byte[] id, int revision) + { + if (revision == 2) + return Rc4(fileKey, Padding); + + var input = new List(Padding); + input.AddRange(id); + using var md5 = MD5.Create(); + + byte[] result = Rc4(fileKey, md5.ComputeHash(input.ToArray())); + for (var i = 1; i <= 19; i++) + { + var key = new byte[fileKey.Length]; + for (var j = 0; j < fileKey.Length; j++) + key[j] = (byte)(fileKey[j] ^ i); + result = Rc4(key, result); + } + return result; + } + + private static byte[] Rc4(byte[] key, byte[] data) + { + var s = new byte[256]; + for (var i = 0; i < 256; i++) + s[i] = (byte)i; + for (int i = 0, j = 0; i < 256; i++) + { + j = (j + s[i] + key[i % key.Length]) & 0xFF; + (s[i], s[j]) = (s[j], s[i]); + } + + var result = new byte[data.Length]; + for (int n = 0, i = 0, j = 0; n < data.Length; n++) + { + i = (i + 1) & 0xFF; + j = (j + s[i]) & 0xFF; + (s[i], s[j]) = (s[j], s[i]); + result[n] = (byte)(data[n] ^ s[(s[i] + s[j]) & 0xFF]); + } + return result; + } + + /// Reads a string entry, whether it is written as a hexadecimal or a literal string. + private static byte[] StringValue(string dictionary, string key) + { + var hex = Regex.Match(dictionary, Regex.Escape(key) + @"\s*<([0-9A-Fa-f\s]*)>", RegexOptions.Singleline); + if (hex.Success) + return FromHex(Regex.Replace(hex.Groups[1].Value, @"\s", "")); + + var literal = Regex.Match(dictionary, Regex.Escape(key) + @"\s*\(", RegexOptions.Singleline); + if (!literal.Success) + return null; + + var bytes = new List(); + var depth = 1; + for (int i = literal.Index + literal.Length; i < dictionary.Length; i++) + { + char c = dictionary[i]; + if (c == '\\') + { + char escaped = dictionary[++i]; + switch (escaped) + { + case 'n': bytes.Add((byte)'\n'); break; + case 'r': bytes.Add((byte)'\r'); break; + case 't': bytes.Add((byte)'\t'); break; + case 'b': bytes.Add(8); break; + case 'f': bytes.Add(12); break; + default: + if (escaped >= '0' && escaped <= '7') + { + int value = escaped - '0'; + for (var digit = 0; digit < 2 && i + 1 < dictionary.Length + && dictionary[i + 1] >= '0' && dictionary[i + 1] <= '7'; digit++) + value = value * 8 + (dictionary[++i] - '0'); + bytes.Add((byte)value); + } + else + bytes.Add((byte)escaped); + break; + } + continue; + } + if (c == '(') depth++; + if (c == ')' && --depth == 0) break; + bytes.Add((byte)c); + } + return bytes.ToArray(); + } + + private static bool StartsWith(byte[] actual, byte[] expected, int count) + { + if (actual == null || actual.Length < count || expected.Length < Math.Min(count, expected.Length)) + return false; + for (var i = 0; i < count && i < expected.Length; i++) + if (actual[i] != expected[i]) + return false; + return true; + } + + private static byte[] Take(byte[] bytes, int count) + { + var result = new byte[count]; + Array.Copy(bytes, result, count); + return result; + } + + /// + /// Latin-1 maps every byte to the character of the same value and back, which is what lets a + /// document be searched as text without disturbing its bytes. Written out rather than taken + /// from Encoding, which does not offer Latin-1 on every framework this builds for. + /// + internal static string Latin1String(byte[] bytes) + { + var text = new char[bytes.Length]; + for (var i = 0; i < bytes.Length; i++) + text[i] = (char)bytes[i]; + return new string(text); + } + + internal static byte[] Latin1Bytes(string text) + { + var bytes = new byte[text.Length]; + for (var i = 0; i < text.Length; i++) + bytes[i] = (byte)text[i]; + return bytes; + } + + private static string ToHex(byte[] bytes) + { + var text = new StringBuilder(); + foreach (byte b in bytes) + text.AppendFormat("{0:X2}", b); + return text.ToString(); + } + + private static byte[] FromHex(string hex) + { + var bytes = new byte[hex.Length / 2]; + for (var i = 0; i < bytes.Length; i++) + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + return bytes; + } + + private static Match Last(string text, string pattern) + { + Match last = null; + foreach (Match m in Regex.Matches(text, pattern, RegexOptions.Singleline)) + last = m; + return last; + } + + private static readonly byte[] Padding = + { + 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, + 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A + }; + } +} diff --git a/PdfSharpCore/Pdf.Internal/PdfEncoders.cs b/PdfSharpCore/Pdf.Internal/PdfEncoders.cs index 7c050919..88f5753b 100644 --- a/PdfSharpCore/Pdf.Internal/PdfEncoders.cs +++ b/PdfSharpCore/Pdf.Internal/PdfEncoders.cs @@ -306,6 +306,21 @@ public static byte[] FormatStringLiteral(byte[] bytes, bool unicode, bool prefix Debug.Assert(!unicode || bytes.Length % 2 == 0, "Odd number of bytes in Unicode string."); + // The byte order mark belongs to the value of the string, not to its syntax, so it has to + // be part of what gets encrypted. Written outside the ciphertext it would be decrypted as + // if it were text, which shifts everything after it and leaves the whole string unreadable + // to every reader but this one. Putting it into the bytes here writes the same characters + // as before when nothing is encrypted. + if (unicode && prefix) + { + var withByteOrderMark = new byte[bytes.Length + 2]; + withByteOrderMark[0] = 0xFE; + withByteOrderMark[1] = 0xFF; + Array.Copy(bytes, 0, withByteOrderMark, 2, bytes.Length); + bytes = withByteOrderMark; + prefix = false; + } + bool encrypted = false; if (securityHandler != null) { diff --git a/PdfSharpCore/Pdf/PdfString.cs b/PdfSharpCore/Pdf/PdfString.cs index 2ed2acbe..216d3b66 100644 --- a/PdfSharpCore/Pdf/PdfString.cs +++ b/PdfSharpCore/Pdf/PdfString.cs @@ -200,7 +200,8 @@ internal PdfStringFlags Flags { get { return _flags; } } - readonly PdfStringFlags _flags; + // Not readonly: decrypting a string can reveal that it is UTF-16BE, see EncryptionValue. + PdfStringFlags _flags; /// /// Gets the string value. @@ -221,6 +222,17 @@ internal byte[] EncryptionValue // BUG: May lead to trouble with the value semantics of PdfString set { + // A text string keeps its byte order mark inside the encrypted bytes, so whether it is + // UTF-16BE only becomes apparent once it has been decrypted. The flags were decided by + // the lexer, which saw the ciphertext and had nothing to go by, so the mark decides + // here and the flags are corrected to match what the value now holds. + if (value.Length >= 2 && value[0] == 0xFE && value[1] == 0xFF) + { + _value = PdfEncoders.RawUnicodeEncoding.GetString(value, 2, value.Length - 2); + _flags = (_flags & ~PdfStringFlags.EncodingMask) | PdfStringFlags.Unicode; + return; + } + var encoding = (PdfStringEncoding)(_flags & PdfStringFlags.EncodingMask); switch (encoding) { diff --git a/PdfSharpCore/Pdf/PdfStringObject.cs b/PdfSharpCore/Pdf/PdfStringObject.cs index 4ec22880..aae3bd91 100644 --- a/PdfSharpCore/Pdf/PdfStringObject.cs +++ b/PdfSharpCore/Pdf/PdfStringObject.cs @@ -123,9 +123,29 @@ public string Value /// internal byte[] EncryptionValue { - // TODO: Unicode case is not handled! - get { return _value == null ? new byte[0] : PdfEncoders.RawEncoding.GetBytes(_value); } - set { _value = PdfEncoders.RawEncoding.GetString(value, 0, value.Length); } + get + { + if (_value == null) + return new byte[0]; + return Encoding == PdfStringEncoding.Unicode + ? PdfEncoders.RawUnicodeEncoding.GetBytes(_value) + : PdfEncoders.RawEncoding.GetBytes(_value); + } + set + { + // As in PdfString: the byte order mark is inside the encrypted bytes, so it is only + // after decrypting that a string can be seen to be UTF-16BE. + if (value.Length >= 2 && value[0] == 0xFE && value[1] == 0xFF) + { + _value = PdfEncoders.RawUnicodeEncoding.GetString(value, 2, value.Length - 2); + Encoding = PdfStringEncoding.Unicode; + return; + } + + _value = Encoding == PdfStringEncoding.Unicode + ? PdfEncoders.RawUnicodeEncoding.GetString(value, 0, value.Length) + : PdfEncoders.RawEncoding.GetString(value, 0, value.Length); + } } /// From 41e0cff2b3f4807b5f57c555f49fb48429b4794f Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Tue, 28 Jul 2026 08:32:50 +1000 Subject: [PATCH 2/2] Break the lines of a long hexadecimal string where they always broke Moving the byte order mark into the bytes moved it into the count the hexadecimal writer breaks lines by, which breaks every 48 bytes. A string of more than 24 characters therefore came out with its line breaks two bytes earlier than before. The breaks are white space inside a hexadecimal string and mean nothing to a reader, but the claim that an unencrypted document is left byte for byte what it was only held for strings too short to break at all, which is exactly what the test used. Count from the text rather than from the start of the bytes, so the mark travels with the text without carrying the count along with it. The test now covers a title long enough to break, and the expected string is built by the loop as it stood before, so it states what is being preserved rather than restating the result. --- .../Security/EncryptedTextStringTests.cs | 36 ++++++++++++++----- PdfSharpCore/Pdf.Internal/PdfEncoders.cs | 7 +++- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs b/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs index 6ae8ea18..4276f6ba 100644 --- a/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs +++ b/PdfSharpCore.Test/Security/EncryptedTextStringTests.cs @@ -83,23 +83,41 @@ public void ADocumentWrittenBeforeTheFixIsStillRead(PdfDocumentSecurityLevel lev reread.Info.Title.Should().Be(Title); } - [Fact] - public void AnUnencryptedDocumentSpellsOutTheByteOrderMarkAsBefore() + [Theory] + [InlineData(Title)] + // Long enough for the hexadecimal string to break across lines, which happens every 48 + // bytes of text. The mark now travels with those bytes, so it must not carry the count + // with it and move every break two bytes along. + [InlineData("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcd")] + public void AnUnencryptedDocumentIsWrittenExactlyAsBefore(string title) { var document = new PdfDocument(); document.AddPage(); - document.Info.Title = Title; + document.Info.Title = title; using var output = new MemoryStream(); document.Save(output, false); - // Nothing is encrypted, so the bytes are what they always were. - var expected = new StringBuilder("/Title '); + StandardSecurity.Latin1String(output.ToArray()) + .Should().Contain("/Title " + HexStringAsItWasWrittenBefore(title)); + } - StandardSecurity.Latin1String(output.ToArray()).Should().Contain(expected.ToString()); + /// + /// The hexadecimal string the writer produced before the byte order mark moved into the + /// bytes: the mark spelled out, then the text, broken after every 48 bytes of that text. + /// + private static string HexStringAsItWasWrittenBefore(string text) + { + var bytes = Encoding.BigEndianUnicode.GetBytes(text); + var hex = new StringBuilder("'); + return hex.ToString(); } private static byte[] SaveEncryptedDocument(PdfDocumentSecurityLevel level) diff --git a/PdfSharpCore/Pdf.Internal/PdfEncoders.cs b/PdfSharpCore/Pdf.Internal/PdfEncoders.cs index 88f5753b..aed8620f 100644 --- a/PdfSharpCore/Pdf.Internal/PdfEncoders.cs +++ b/PdfSharpCore/Pdf.Internal/PdfEncoders.cs @@ -311,6 +311,7 @@ public static byte[] FormatStringLiteral(byte[] bytes, bool unicode, bool prefix // if it were text, which shifts everything after it and leaves the whole string unreadable // to every reader but this one. Putting it into the bytes here writes the same characters // as before when nothing is encrypted. + int byteOrderMarkLength = 0; if (unicode && prefix) { var withByteOrderMark = new byte[bytes.Length + 2]; @@ -318,6 +319,7 @@ public static byte[] FormatStringLiteral(byte[] bytes, bool unicode, bool prefix withByteOrderMark[1] = 0xFF; Array.Copy(bytes, 0, withByteOrderMark, 2, bytes.Length); bytes = withByteOrderMark; + byteOrderMarkLength = 2; prefix = false; } @@ -420,7 +422,10 @@ public static byte[] FormatStringLiteral(byte[] bytes, bool unicode, bool prefix for (int idx = 0; idx < count; idx += 2) { pdf.AppendFormat("{0:X2}{1:X2}", bytes[idx], bytes[idx + 1]); - if (idx != 0 && (idx % 48) == 0) + // The mark is part of the bytes now, so count from the text that follows it + // and the lines break where they always did. + int positionInText = idx - byteOrderMarkLength; + if (positionInText != 0 && (positionInText % 48) == 0) pdf.Append("\n"); } pdf.Append(">");