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 }