1 /// Provides a reading library for crunch texture packing files.
2 /// License: public domain
3 module crunch;
4 
5 import std.algorithm;
6 import std.bitmanip;
7 import std.conv;
8 import std.json;
9 import std.parallelism;
10 import std.range;
11 
12 import std.xml;
13 
14 static immutable string ImageSorter = "a.name < b.name";
15 
16 ///
17 struct Crunch
18 {
19 	///
20 	static immutable string magicNumber = "crn\xC7I1[";
21 
22 	///
23 	enum Flags : ubyte
24 	{
25 		none = 0,
26 		premultiplied = 1 << 0,
27 		trimEnabled = 1 << 1,
28 		rotationEnabled = 1 << 2,
29 		unique = 1 << 3,
30 	}
31 
32 	/// Represents a source image that got packed.
33 	struct Image
34 	{
35 		/// Original name of the source image without extension.
36 		string name;
37 		/// Rectangle in the texture where to find this image.
38 		ushort x, y, width, height;
39 		/// Offset where texture would be with additional transparency. Can be negative.
40 		short frameX, frameY;
41 		/// Actual size how big texture would be with transparency.
42 		ushort frameWidth, frameHeight;
43 		/// True if rotated by 90 degrees (clockwise).
44 		bool rotated;
45 	}
46 
47 	/// Represents a single png containing a set of packed images.
48 	struct Texture
49 	{
50 		/// Texture filename
51 		string name;
52 		/// Sorted list of images mapped in this texture
53 		Image[] images;
54 	}
55 
56 	/// Information about all generated textures
57 	Texture[] textures;
58 
59 	/// What size was passed to crunch when packing
60 	ushort textureSize;
61 	/// Amount of padding between textures
62 	byte padding;
63 	/// Further flags passed into crunch
64 	Flags flags;
65 
66 	/// Finds an image by name in the textures with sorted image lists
67 	Image* find(string name)
68 	{
69 		foreach (ref tex; textures)
70 		{
71 			auto images = assumeSorted!ImageSorter(tex.images);
72 
73 			auto match = images.trisect(Image(name));
74 
75 			if (match[1].length)
76 				return &tex.images[match[0].length];
77 		}
78 
79 		return null;
80 	}
81 }
82 
83 /// Parses a crunch file from --compact or --binary output. For old --binary format files no trim and no rotate is assumed.
84 /// Params:
85 ///     data = binary data to parse
86 ///     allowBinary = true to make a failed magicnumber check interpret the file as --binary output (no metadata)
87 ///     nameDup = method to duplicate names of textures and images. Must be the input name because it is sorted.
88 Crunch crunchFromCompact(alias nameDup)(const ubyte[] data, bool allowBinary)
89 {
90 	Crunch ret;
91 
92 	size_t i;
93 	ushort alignment;
94 	int ver = -1;
95 
96 	if (data.length < 8 || data[0 .. Crunch.magicNumber.length] != Crunch.magicNumber)
97 	{
98 		if (!allowBinary)
99 			throw new Exception("Passed data is not a crunch file");
100 	}
101 	else
102 	{
103 		i += Crunch.magicNumber.length;
104 		ver = data.peek!ubyte(&i);
105 
106 		if (ver != 0)
107 			throw new Exception(text("Unsupported crunch version: ", ver));
108 
109 		if (i + 6 > data.length)
110 			throw new Exception("Invalid file, not enough data");
111 
112 		alignment = data.peek!(ushort, Endian.littleEndian)(&i);
113 		ret.textureSize = data.peek!(ushort, Endian.littleEndian)(&i);
114 		ret.padding = data.peek!ubyte(&i);
115 		ret.flags = cast(Crunch.Flags) data.peek!ubyte(&i);
116 	}
117 
118 	ushort numTextures = data.peek!(ushort, Endian.littleEndian)(&i);
119 
120 	ret.textures.length = numTextures;
121 	foreach (ref texture; ret.textures)
122 	{
123 		texture.name = nameDup(data.readString(&i, ver));
124 		ushort num = data.peek!(ushort, Endian.littleEndian)(&i);
125 		texture.images.length = num;
126 
127 		if (ver == -1)
128 		{
129 			foreach (n; 0 .. num)
130 			{
131 				texture.images[n].name = nameDup(data.readString(&i, ver));
132 				texture.images[n].x = data.peek!(ushort, Endian.littleEndian)(&i);
133 				texture.images[n].y = data.peek!(ushort, Endian.littleEndian)(&i);
134 				texture.images[n].width = texture.images[n].frameWidth = data.peek!(ushort,
135 						Endian.littleEndian)(&i);
136 				texture.images[n].height = texture.images[n].frameHeight = data.peek!(ushort,
137 						Endian.littleEndian)(&i);
138 			}
139 
140 			texture.images.sort!ImageSorter;
141 		}
142 		else
143 		{
144 			const size_t base = i = alignNumber(i, alignment);
145 			i += num * alignment;
146 			foreach (n; iota(num).parallel)
147 			{
148 				size_t index = base + n * alignment;
149 
150 				texture.images[n] = data.crunchImageFromCompactNoName(ret.flags, &index);
151 				texture.images[n].name = nameDup(data.readString(&index, ver));
152 			}
153 		}
154 	}
155 
156 	return ret;
157 }
158 
159 /// Parses a crunch file from --compact output. Uses the GC .idup to make strings
160 Crunch crunchFromCompact(ubyte[] data, bool allowBinary)
161 {
162 	return crunchFromCompact!(name => name.idup)(data, allowBinary);
163 }
164 
165 private Crunch.Image crunchImageFromCompactNoName(const ubyte[] data,
166 		Crunch.Flags flags, size_t* index)
167 {
168 	Crunch.Image ret;
169 	ret.x = data.peek!(ushort, Endian.littleEndian)(index);
170 	ret.y = data.peek!(ushort, Endian.littleEndian)(index);
171 	ret.width = ret.frameWidth = data.peek!(ushort, Endian.littleEndian)(index);
172 	ret.height = ret.frameHeight = data.peek!(ushort, Endian.littleEndian)(index);
173 	if ((flags & Crunch.Flags.trimEnabled) == Crunch.Flags.trimEnabled)
174 	{
175 		ret.frameX = data.peek!(ushort, Endian.littleEndian)(index);
176 		ret.frameY = data.peek!(ushort, Endian.littleEndian)(index);
177 		ret.frameWidth = data.peek!(ushort, Endian.littleEndian)(index);
178 		ret.frameHeight = data.peek!(ushort, Endian.littleEndian)(index);
179 	}
180 	if ((flags & Crunch.Flags.rotationEnabled) == Crunch.Flags.rotationEnabled)
181 		ret.rotated = data.peek!ubyte(index) != 0;
182 	return ret;
183 }
184 
185 /// Finds one image in a compact file efficiently. Returns Crunch.Image.init when it couldn't be found.
186 Crunch.Image searchInCompact(ubyte[] data, string name, out int textureIndex)
187 {
188 	size_t i;
189 	ushort alignment;
190 
191 	if (data.length < 8 || data[0 .. Crunch.magicNumber.length] != Crunch.magicNumber)
192 	{
193 		throw new Exception("Passed data is not a crunch file");
194 	}
195 	i += Crunch.magicNumber.length;
196 	const int ver = data.peek!ubyte(&i);
197 
198 	if (ver != 0)
199 		throw new Exception(text("Unsupported crunch version: ", ver));
200 
201 	alignment = data.peek!(ushort, Endian.littleEndian)(&i);
202 	Crunch.Flags flags = cast(Crunch.Flags) data[i + 3];
203 	i += 4;
204 
205 	size_t imageNameOffset = 8;
206 	if ((flags & Crunch.Flags.trimEnabled) == Crunch.Flags.trimEnabled)
207 		imageNameOffset += 8;
208 	if ((flags & Crunch.Flags.rotationEnabled) == Crunch.Flags.rotationEnabled)
209 		imageNameOffset += 1;
210 
211 	ushort numTextures = data.peek!(ushort, Endian.littleEndian)(&i);
212 	foreach (n; 0 .. numTextures)
213 	{
214 		data.readString(&i, ver);
215 		ushort numImages = data.peek!(ushort, Endian.littleEndian)(&i);
216 		i = alignNumber(i, alignment);
217 
218 		auto chunks = data[i .. i += numImages * alignment].chunks(alignment);
219 		auto sorted = chunks.map!((a) {
220 			size_t subIndex = imageNameOffset;
221 			return a.readString(&subIndex, ver);
222 		})
223 			.assumeSorted!"a < b";
224 
225 		auto parts = sorted.trisect(name);
226 		if (parts[1].length)
227 		{
228 			textureIndex = n;
229 			size_t index = 0;
230 			auto ret = crunchImageFromCompactNoName(chunks[parts[0].length], flags, &index);
231 			ret.name = name;
232 			return ret;
233 		}
234 	}
235 
236 	return Crunch.Image.init;
237 }
238 
239 private char[] readString(const ubyte[] data, size_t* i, int ver)
240 {
241 	static char[256] shortBuffer;
242 
243 	if (ver == -1)
244 	{
245 		int n = 0;
246 		while (data[*i] != 0)
247 		{
248 			shortBuffer[n++] = data[*i];
249 			i++;
250 		}
251 		i++;
252 
253 		return cast(char[]) shortBuffer[0 .. n];
254 	}
255 	else
256 	{
257 		ushort size = data.peek!(ushort, Endian.littleEndian)(i);
258 		return cast(char[]) data[*i .. *i += size];
259 	}
260 }
261 
262 private size_t alignNumber(size_t n, size_t alignment)
263 {
264 	alignment--;
265 	return (n + alignment) & ~alignment;
266 }
267 
268 /// Reads a crunch file from json (either old or new format)
269 Crunch crunchFromJson(JSONValue json)
270 {
271 	if (json.type != JSONType.object)
272 		throw new Exception("JSON is not an object");
273 
274 	Crunch ret;
275 
276 	auto ver = "version" in json ? json["version"].integer : 0;
277 	if (ver == 0)
278 	{
279 		// no metadata, we can just guess trim and rotate
280 	}
281 	else if (ver == 1)
282 	{
283 		ret.textureSize = cast(ushort) json["size"].integer;
284 		ret.padding = cast(byte) json["padding"].integer;
285 		if (json["premultiplied"].boolean)
286 			ret.flags |= Crunch.Flags.premultiplied;
287 		if (json["trim"].boolean)
288 			ret.flags |= Crunch.Flags.trimEnabled;
289 		if (json["rotate"].boolean)
290 			ret.flags |= Crunch.Flags.rotationEnabled;
291 		if (json["unique"].boolean)
292 			ret.flags |= Crunch.Flags.unique;
293 	}
294 	else
295 		throw new Exception("Unsupported JSON version");
296 
297 	auto textures = json["textures"].array;
298 	ret.textures.length = textures.length;
299 	foreach (i, texture; textures)
300 	{
301 		ret.textures[i].name = texture["name"].str;
302 		auto images = texture["images"].array;
303 		ret.textures[i].images.length = images.length;
304 		foreach (j, image; images)
305 		{
306 			ret.textures[i].images[j].name = image["n"].str;
307 			ret.textures[i].images[j].x = cast(ushort) image["x"].integer;
308 			ret.textures[i].images[j].y = cast(ushort) image["y"].integer;
309 			ret.textures[i].images[j].width = ret.textures[i].images[j].frameWidth = cast(
310 					ushort) image["w"].integer;
311 			ret.textures[i].images[j].height = ret.textures[i].images[j].frameHeight = cast(
312 					ushort) image["h"].integer;
313 
314 			if ("fx" in image)
315 			{
316 				ret.flags |= Crunch.Flags.trimEnabled;
317 				ret.textures[i].images[j].frameX = cast(short) image["fx"].integer;
318 				ret.textures[i].images[j].frameY = cast(short) image["fy"].integer;
319 				ret.textures[i].images[j].frameWidth = cast(ushort) image["fw"].integer;
320 				ret.textures[i].images[j].frameHeight = cast(ushort) image["fh"].integer;
321 			}
322 
323 			if (auto rot = "r" in image)
324 			{
325 				ret.flags |= Crunch.Flags.rotationEnabled;
326 				ret.textures[i].images[j].rotated = rot.boolean;
327 			}
328 		}
329 
330 		ret.textures[i].images.sort!ImageSorter;
331 	}
332 
333 	return ret;
334 }
335 
336 /// Parses an xml file for crunch. Uses std.xml so it might get deprecated eventually.
337 Crunch crunchFromXml(string xmlStr)
338 {
339 	Crunch ret;
340 
341 	auto xml = new DocumentParser(xmlStr);
342 	xml.onStartTag["tex"] = (ElementParser xml) {
343 		Crunch.Texture texture;
344 		texture.name = xml.tag.attr["n"];
345 
346 		xml.onStartTag["img"] = (ElementParser xml) {
347 			Crunch.Image image;
348 			image.name = xml.tag.attr["n"];
349 			image.x = cast(ushort) xml.tag.attr["x"].to!int;
350 			image.y = cast(ushort) xml.tag.attr["y"].to!int;
351 			image.width = image.frameWidth = cast(ushort) xml.tag.attr["w"].to!int;
352 			image.height = image.frameHeight = cast(ushort) xml.tag.attr["h"].to!int;
353 			if ("fx" in xml.tag.attr)
354 			{
355 				ret.flags |= Crunch.Flags.trimEnabled;
356 				image.frameX = cast(short) xml.tag.attr["fx"].to!int;
357 				image.frameY = cast(short) xml.tag.attr["fy"].to!int;
358 				image.frameWidth = cast(ushort) xml.tag.attr["fw"].to!int;
359 				image.frameHeight = cast(ushort) xml.tag.attr["fh"].to!int;
360 			}
361 			if (auto rot = "r" in xml.tag.attr)
362 			{
363 				ret.flags |= Crunch.Flags.rotationEnabled;
364 				image.rotated = rot.length && *rot != "0";
365 			}
366 			texture.images ~= image;
367 		};
368 
369 		xml.parse();
370 
371 		texture.images.sort!ImageSorter;
372 		ret.textures ~= texture;
373 	};
374 
375 	auto rootAttrs = xml.tag.attr;
376 	if ("premultiplied" in rootAttrs)
377 		ret.flags |= Crunch.Flags.premultiplied;
378 	if ("trim" in rootAttrs)
379 		ret.flags |= Crunch.Flags.trimEnabled;
380 	if ("rotate" in rootAttrs)
381 		ret.flags |= Crunch.Flags.rotationEnabled;
382 	if ("unique" in rootAttrs)
383 		ret.flags |= Crunch.Flags.unique;
384 
385 	if (auto size = "size" in rootAttrs)
386 		ret.textureSize = cast(ushort)(*size).to!int;
387 	if (auto padding = "padding" in rootAttrs)
388 		ret.padding = cast(byte)(*padding).to!int;
389 
390 	xml.parse();
391 
392 	return ret;
393 }
394 
395 unittest
396 {
397 	auto dat = crunchFromXml(q{<?xml version="1.0"?>
398 		<atlas version="1" size="2048" padding="1" trim="trim" rotate="rotate" unique="unique">
399 			<tex n="packed0">
400 				<img n="sprGameOver_0" x="0" y="0" w="757" h="158" fx="0" fy="0" fw="757" fh="158" r="0" />
401 				<img n="bStars" x="757" y="0" w="256" h="256" fx="0" fy="0" fw="256" fh="256" r="1" />
402 			</tex>
403 			<tex n="packed1">
404 				<img n="sprPlayer" x="40" y="40" w="32" h="64" fx="-5" fy="-2" fw="40" fh="80" r="0" />
405 				<img n="bGrass" x="757" y="0" w="256" h="256" fx="0" fy="0" fw="256" fh="256" r="0" />
406 			</tex>
407 		</atlas>});
408 
409 	auto json = crunchFromJson(parseJSON(q{{
410 		"version": 1,
411 		"size": 2048,
412 		"padding": 1,
413 		"premultiplied": false,
414 		"trim": true,
415 		"rotate": true,
416 		"unique": true,
417 		"textures":[
418 			{
419 				"name":"packed0",
420 				"images":[
421 					{ "n":"sprGameOver_0", "x":0, "y":0, "w":757, "h":158, "fx":0, "fy":0, "fw":757, "fh":158, "r":false },
422 					{ "n":"bStars", "x":757, "y":0, "w":256, "h":256, "fx":0, "fy":0, "fw":256, "fh":256, "r":true }
423 				]
424 			},
425 			{
426 				"name":"packed1",
427 				"images":[
428 					{ "n":"sprPlayer", "x":40, "y":40, "w":32, "h":64, "fx":-5, "fy":-2, "fw":40, "fh":80, "r":false },
429 					{ "n":"bGrass", "x":757, "y":0, "w":256, "h":256, "fx":0, "fy":0, "fw":256, "fh":256, "r":false }
430 				]
431 			}
432 		]
433 	}}));
434 
435 	//dfmt off
436 	auto bin = crunchFromCompact(cast(ubyte[])(
437 			hexString!"63 72 6E C7 49 31 5B 00" ~ // magic number
438 			hexString!"40 00 00 08 01 0E 02 00" ~ // align, size, padding, flags, num textures
439 			hexString!"07 00 70 61 63 6B 65 64 30 02 00 00 00 00 00 00" ~ // packed0, 0 entries
440 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
441 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
442 			hexString!"F5 02 00 00 00 01 00 01 00 00 00 00 00 01 00 01" ~ // entry 1
443 			hexString!"01 06 00 62 53 74 61 72 73 00 00 00 00 00 00 00" ~ //
444 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
445 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
446 			hexString!"00 00 00 00 F5 02 9E 00 00 00 00 00 F5 02 9E 00" ~ // entry 2
447 			hexString!"00 0D 00 73 70 72 47 61 6d 65 4f 76 65 72 5f 30" ~ //
448 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
449 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
450 			hexString!"07 00 70 61 63 6B 65 64 31 02 00 00 00 00 00 00" ~ // packed1, 0 entries
451 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
452 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
453 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
454 			hexString!"F5 02 00 00 00 01 00 01 00 00 00 00 00 01 00 01" ~ // entry 1
455 			hexString!"00 06 00 62 47 72 61 73 73 00 00 00 00 00 00 00" ~ //
456 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
457 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
458 			hexString!"28 00 28 00 20 00 40 00 FB FF FE FF 28 00 50 00" ~ // entry 2
459 			hexString!"00 09 00 73 70 72 50 6c 61 79 65 72 00 00 00 00" ~ //
460 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" ~ //
461 			hexString!"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"), false);
462 	//dfmt on
463 
464 	assert(dat.textureSize == 2048);
465 	assert(dat.padding == 1);
466 	assert(dat.flags == (Crunch.Flags.trimEnabled | Crunch.Flags.rotationEnabled | Crunch.Flags.unique),
467 			dat.flags.to!string);
468 	assert(dat.textures.length == 2);
469 
470 	assert(dat.textures[0].name == "packed0");
471 	assert(dat.textures[0].images.length == 2);
472 
473 	assert(dat.textures[0].images[0].name == "bStars");
474 	assert(dat.textures[0].images[0].x == 757);
475 	assert(dat.textures[0].images[0].y == 0);
476 	assert(dat.textures[0].images[0].width == 256);
477 	assert(dat.textures[0].images[0].height == 256);
478 	assert(dat.textures[0].images[0].frameX == 0);
479 	assert(dat.textures[0].images[0].frameY == 0);
480 	assert(dat.textures[0].images[0].frameWidth == 256);
481 	assert(dat.textures[0].images[0].frameHeight == 256);
482 	assert(dat.textures[0].images[0].rotated);
483 
484 	assert(dat.textures[0].images[1].name == "sprGameOver_0");
485 	assert(dat.textures[0].images[1].x == 0);
486 	assert(dat.textures[0].images[1].y == 0);
487 	assert(dat.textures[0].images[1].width == 757);
488 	assert(dat.textures[0].images[1].height == 158);
489 	assert(dat.textures[0].images[1].frameX == 0);
490 	assert(dat.textures[0].images[1].frameY == 0);
491 	assert(dat.textures[0].images[1].frameWidth == 757);
492 	assert(dat.textures[0].images[1].frameHeight == 158);
493 	assert(!dat.textures[0].images[1].rotated);
494 
495 	assert(dat.textures[1].name == "packed1");
496 	assert(dat.textures[1].images.length == 2);
497 
498 	assert(dat.textures[1].images[0].name == "bGrass");
499 	assert(dat.textures[1].images[0].x == 757);
500 	assert(dat.textures[1].images[0].y == 0);
501 	assert(dat.textures[1].images[0].width == 256);
502 	assert(dat.textures[1].images[0].height == 256);
503 	assert(dat.textures[1].images[0].frameX == 0);
504 	assert(dat.textures[1].images[0].frameY == 0);
505 	assert(dat.textures[1].images[0].frameWidth == 256);
506 	assert(dat.textures[1].images[0].frameHeight == 256);
507 	assert(!dat.textures[1].images[0].rotated);
508 
509 	assert(dat.textures[1].images[1].name == "sprPlayer");
510 	assert(dat.textures[1].images[1].x == 40);
511 	assert(dat.textures[1].images[1].y == 40);
512 	assert(dat.textures[1].images[1].width == 32);
513 	assert(dat.textures[1].images[1].height == 64);
514 	assert(dat.textures[1].images[1].frameX == -5);
515 	assert(dat.textures[1].images[1].frameY == -2);
516 	assert(dat.textures[1].images[1].frameWidth == 40);
517 	assert(dat.textures[1].images[1].frameHeight == 80);
518 	assert(!dat.textures[1].images[1].rotated);
519 
520 	assert(dat == json);
521 	assert(dat == bin);
522 }