Skip to content

Commit 4404227

Browse files
authored
Added support for importing PPM files (#2031)
1 parent 888a167 commit 4404227

File tree

5 files changed

+217
-21
lines changed

5 files changed

+217
-21
lines changed

Pinta.Core/ImageFormats/NetpbmPortablePixmap.cs

Lines changed: 176 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,42 +6,206 @@
66

77
namespace Pinta.Core;
88

9-
public sealed class NetpbmPortablePixmap : IImageExporter
9+
public sealed class NetpbmPortablePixmap : IImageExporter, IImageImporter
1010
{
11+
private const string MAGIC_SEQUENCE = "P3"; // Magic sequence for text-based portable pixmap format
12+
13+
public Document Import (Gio.File file)
14+
{
15+
using GioStream stream = new GioStream (file.Read (cancellable: null));
16+
using StreamReader reader = new (stream, Encoding.ASCII);
17+
18+
PpmTokenReader tokenizer = new (reader);
19+
20+
string magic = tokenizer.ReadToken ();
21+
if (magic != MAGIC_SEQUENCE)
22+
throw new FormatException ($"Expected '{MAGIC_SEQUENCE}' magic sequence, got '{magic}'");
23+
24+
int width = tokenizer.ReadPositiveInt ("Image width");
25+
int height = tokenizer.ReadPositiveInt ("Image height");
26+
int maxValue = tokenizer.ReadPositiveInt ("Max color value");
27+
28+
Size imageSize = new (width, height);
29+
30+
Document newDocument = new (
31+
PintaCore.Actions,
32+
PintaCore.Tools,
33+
PintaCore.Workspace,
34+
imageSize,
35+
file,
36+
"ppm");
37+
38+
Layer layer = newDocument.Layers.AddNewLayer (file.GetDisplayName ());
39+
Span<ColorBgra> pixelData = layer.Surface.GetPixelData ();
40+
41+
layer.Surface.Flush ();
42+
43+
int pixelCount = width * height;
44+
if (maxValue == 255) {
45+
for (int i = 0; i < pixelCount; i++)
46+
pixelData[i] = FastReadPixel (ref tokenizer);
47+
} else {
48+
for (int i = 0; i < pixelCount; i++)
49+
pixelData[i] = ReadPixel (ref tokenizer, maxValue);
50+
}
51+
52+
layer.Surface.MarkDirty ();
53+
54+
return newDocument;
55+
56+
static ColorBgra FastReadPixel (ref PpmTokenReader tokens)
57+
{
58+
int r = tokens.ReadColorComponent (255);
59+
int g = tokens.ReadColorComponent (255);
60+
int b = tokens.ReadColorComponent (255);
61+
return ColorBgra.FromBgra (
62+
b: (byte) b,
63+
g: (byte) g,
64+
r: (byte) r,
65+
a: byte.MaxValue);
66+
}
67+
68+
static ColorBgra ReadPixel (ref PpmTokenReader tokens, int max)
69+
{
70+
int r = tokens.ReadColorComponent (max);
71+
int g = tokens.ReadColorComponent (max);
72+
int b = tokens.ReadColorComponent (max);
73+
return ColorBgra.FromBgra (
74+
b: ScaleToByteRange (b, max),
75+
g: ScaleToByteRange (g, max),
76+
r: ScaleToByteRange (r, max),
77+
a: byte.MaxValue);
78+
}
79+
}
80+
81+
private static byte ScaleToByteRange (int value, int maxValue)
82+
{
83+
int rounded = (int) (value * 255.0 / maxValue);
84+
int clamped = Math.Clamp (rounded, byte.MinValue, byte.MaxValue);
85+
return (byte) clamped;
86+
}
87+
1188
public void Export (ImageSurface flattenedImage, Stream outputStream)
1289
{
1390
using StreamWriter writer = new (outputStream, Encoding.ASCII) {
14-
NewLine = "\n", // Always use LF endings to generate the same output on every platform and simplify unit tests
91+
NewLine = "\n", // Same output, including line endings, on every platform
1592
};
93+
1694
Size imageSize = flattenedImage.GetSize ();
1795
ReadOnlySpan<ColorBgra> pixelData = flattenedImage.GetReadOnlyPixelData ();
18-
writer.WriteLine ("P3"); // Magic number for text-based portable pixmap format
96+
97+
writer.WriteLine (MAGIC_SEQUENCE);
1998
writer.WriteLine ($"{imageSize.Width} {imageSize.Height}");
2099
writer.WriteLine (byte.MaxValue);
100+
21101
for (int row = 0; row < imageSize.Height; row++) {
22102
int rowStart = row * imageSize.Width;
23103
int rowEnd = rowStart + imageSize.Width;
24104
for (int index = rowStart; index < rowEnd; index++) {
25105
ColorBgra color = pixelData[index];
26-
string r = color.R.ToString ().PadLeft (3, ' ');
27-
string g = color.G.ToString ().PadLeft (3, ' ');
28-
string b = color.B.ToString ().PadLeft (3, ' ');
29-
writer.Write ($"{r} {g} {b}");
106+
WritePixel (color);
30107
if (index != rowEnd - 1)
31108
writer.Write (" ");
32109
}
33110
writer.WriteLine ();
34111
}
35-
writer.Close ();
112+
113+
void WritePixel (ColorBgra color)
114+
{
115+
string r = RepresentColorComponent (color.R);
116+
string g = RepresentColorComponent (color.G);
117+
string b = RepresentColorComponent (color.B);
118+
writer.Write ($"{r} {g} {b}");
119+
}
120+
121+
static string RepresentColorComponent (byte component)
122+
=> component.ToString ().PadLeft (3, ' ');
36123
}
37124

38-
public void Export (
39-
Document document,
40-
Gio.File file,
41-
Window parent)
125+
public void Export (Document document, Gio.File file, Window parent)
42126
{
43127
using ImageSurface flattenedImage = document.GetFlattenedImage ();
44128
using GioStream outputStream = new (file.Replace ());
45129
Export (flattenedImage, outputStream);
46130
}
131+
132+
private readonly ref struct PpmTokenReader
133+
{
134+
private readonly TextReader reader;
135+
internal PpmTokenReader (TextReader reader)
136+
{
137+
this.reader = reader;
138+
}
139+
140+
public string ReadToken ()
141+
{
142+
SkipWhitespaceAndComments ();
143+
StringBuilder token = new ();
144+
int c;
145+
while ((c = reader.Read ()) != -1) {
146+
char ch = (char) c;
147+
if (char.IsWhiteSpace (ch)) break;
148+
if (ch == '#') { // Comment
149+
reader.ReadLine (); // Skip rest of line
150+
break;
151+
}
152+
token.Append (ch);
153+
}
154+
return
155+
token.Length > 0
156+
? token.ToString ()
157+
: throw new FormatException ("Unexpected end of data");
158+
}
159+
160+
public int ReadPositiveInt (string fieldName)
161+
{
162+
int value = ReadInt ();
163+
return
164+
value > 0
165+
? value
166+
: throw new FormatException ($"Invalid {fieldName}: {value}");
167+
}
168+
169+
public int ReadColorComponent (int maxValue)
170+
{
171+
int value = ReadInt ();
172+
return
173+
value >= 0 && value <= maxValue
174+
? value
175+
: throw new FormatException ($"Color component {value} outside range 0..{maxValue}");
176+
}
177+
178+
private int ReadInt ()
179+
{
180+
SkipWhitespaceAndComments ();
181+
int value = 0;
182+
bool found = false;
183+
int c;
184+
while ((c = reader.Peek ()) is >= '0' and <= '9') {
185+
reader.Read ();
186+
value = value * 10 + (c - '0');
187+
found = true;
188+
}
189+
return
190+
found
191+
? value
192+
: throw new FormatException ("Expected integer value");
193+
}
194+
195+
private void SkipWhitespaceAndComments ()
196+
{
197+
while (reader.Peek () is int c and not -1) {
198+
char ch = (char) c;
199+
if (char.IsWhiteSpace (ch)) {
200+
reader.Read ();
201+
continue;
202+
}
203+
if (ch == '#') { // Comment
204+
reader.ReadLine (); // Skip rest of line
205+
continue;
206+
}
207+
break;
208+
}
209+
}
210+
}
47211
}

Pinta.Core/Managers/ImageConverterManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ private static IEnumerable<FormatDescriptor> GetInitialFormats ()
6565
displayPrefix: "Netpbm Portable Pixmap",
6666
extensions: ["ppm", "PPM"],
6767
mimes: ["image/x-portable-pixmap"], // Not official, but conventional
68-
importer: null,
68+
importer: netpbmPortablePixmap,
6969
exporter: netpbmPortablePixmap,
7070
supportsLayers: false
7171
);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
P3
2+
3 2 # A comment just because we can
3+
# Another comment, lol
4+
1 # Max is nonstandard
5+
# Indented comment for more fun
6+
# Look how inconsistent the spacing :)
7+
1 0 # This comment splits the first two channels from the third
8+
0
9+
# 1 1 1 # This should not be read
10+
# Look at the trailing space below
11+
0 1 0 0 0 1
12+
13+
14+
1 1 0 1 1 1 0 0 0
15+
# The following data goes beyond what was specified in the header,
16+
# and it should be ignored. In fact, let's add an out-of-range value
17+
1 0 1 65535

tests/Pinta.Core.Tests/Assets/sixcolorsoutput_lf.ppm renamed to tests/Pinta.Core.Tests/Assets/sixcolors_standard_lf.ppm

File renamed without changes.

tests/Pinta.Core.Tests/FileFormatTests.cs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ namespace Pinta.Core.Tests;
77
[TestFixture]
88
internal sealed class FileFormatTests
99
{
10-
[TestCase ("sixcolorsinput.gif", "sixcolorsoutput_lf.ppm")]
10+
[TestCase ("sixcolorsinput.gif", "sixcolors_standard_lf.ppm")]
11+
[TestCase ("sixcolorsinput.gif", "sixcolors_chaotic.ppm")]
1112
public void Files_NotEqual (string file1, string file2)
1213
{
1314
string path1 = Utilities.GetAssetPath (file1);
@@ -19,9 +20,9 @@ public void Files_NotEqual (string file1, string file2)
1920
public void Export_NetpbmPixmap_TextBased (string inputFile, IEnumerable<string> acceptableOutputs)
2021
{
2122
string inputFilePath = Utilities.GetAssetPath (inputFile);
22-
ImageSurface loaded = Utilities.LoadImage (inputFilePath);
23+
using ImageSurface loaded = Utilities.LoadImage (inputFilePath);
2324
NetpbmPortablePixmap exporter = new ();
24-
Gio.MemoryOutputStream memoryOutput = Gio.MemoryOutputStream.NewResizable ();
25+
using Gio.MemoryOutputStream memoryOutput = Gio.MemoryOutputStream.NewResizable ();
2526
using GioStream outputStream = new (memoryOutput);
2627
exporter.Export (loaded, outputStream);
2728
outputStream.Close ();
@@ -33,18 +34,32 @@ public void Export_NetpbmPixmap_TextBased (string inputFile, IEnumerable<string>
3334
var bytesReader = Gio.DataInputStream.New (bytesStream);
3435
string filePath = Utilities.GetAssetPath (fileName);
3536
using var context = Utilities.OpenFile (filePath);
36-
if (Utilities.AreFilesEqual (bytesReader, context.DataStream)) {
37-
matched = true;
38-
break;
39-
}
37+
if (!Utilities.AreFilesEqual (bytesReader, context.DataStream)) continue;
38+
matched = true;
39+
break;
4040
}
4141
Assert.That (matched, Is.True);
4242
}
4343

44+
// TODO: This is just for reference. Find a way to get the image importers not to depend on PintaCore
45+
46+
//[TestCase ("sixcolorsinput.gif", "sixcolors_standard_lf.ppm")]
47+
//[TestCase ("sixcolorsinput.gif", "sixcolors_chaotic.ppm")]
48+
//public void Import_NetpbmPixmap_TextBased (string referenceImageName, string ppmFileName)
49+
//{
50+
// string ppmFilePath = Utilities.GetAssetPath (ppmFileName);
51+
// string referenceImagePath = Utilities.GetAssetPath (referenceImageName);
52+
// using ImageSurface loaded = Utilities.LoadImage (referenceImagePath);
53+
// using Gio.File ppmFile = Gio.FileHelper.NewForPath (ppmFilePath);
54+
// NetpbmPortablePixmap importer = new ();
55+
// Document importedPpm = importer.Import (ppmFile);
56+
// Utilities.CompareImages (importedPpm.Layers[0].Surface, loaded);
57+
//}
58+
4459
static readonly IReadOnlyList<TestCaseData> netpbm_pixmap_text_cases = [
4560
new (
4661
"sixcolorsinput.gif",
47-
new[] { "sixcolorsoutput_lf.ppm" }
62+
new[] { "sixcolors_standard_lf.ppm" }
4863
),
4964
];
5065
}

0 commit comments

Comments
 (0)