2010. május 16., vasárnap

Adott pont vonzáskörzetében való keresés MySQL segítségével

Azt a feladatot kaptam, hogy saját adatbázisból vendéglátó-egységeket tegyek kereshetővé, és a találatokat jelenítsem meg egy térképen. Hogy ne legyen olyan egyszerű a feladat, további kérés volt, hogy Budapesten a szomszédos kerületek találatait is mutassam meg, egyébként pedig 50 km-es körzetben lévőket. Bár a megoldás nem kapcsolódik közvetlenül a Java-hoz, mégis úgy gondoltam talán érdekes lehet. Igyekeztem úgy kivitelezni a dolgot, hogy a MySQL adatbázisból ne kerüljön ki olyan érték, amelyet nem kell megjeleníteni.
Lássuk először az 50 km-es körzet problémáját:
Először is létrehoztam két triggert, amik a vendéglátó-egység beszúrása vagy módosítása után futnak le, és egész egyszerűen annyit tesznek, hogy a beállított longitude és latitude értékből a coordinate mezőben eltárolnak egy POINT objektumot. A POINT beépített MySQL objektum, így értelem-szerűen a mező típusának is az van deklarálva.
DELIMITER //

CREATE TRIGGER RefreshHorecaPointInsert BEFORE INSERT ON `horeca`
FOR EACH ROW
BEGIN
    SET NEW.`coordinate` = POINT(NEW.`longitude`, NEW.`latitude`);
END//

CREATE TRIGGER RefreshHorecaPointUpdate BEFORE UPDATE ON `horeca`
FOR EACH ROW
BEGIN
    SET NEW.`coordinate` = POINT(NEW.`longitude`, NEW.`latitude`);
END//

DELIMITER ;
A következő lépés már igen egyszerű, mivel a MySQL (is) rendelkezik beépített geometriai funkciókkal. Feladatunk annyi, hogy a POLYGONE objektumot felhasználva az első találat koordinátái köré egy tetszőleges sokszöget rajzoljunk. Bár az 50 km-es körzet meghatározásához egy kört kellene rajzolni, de annak bonyolultsága és gyakorlati haszna között fennálló aránytalanság miatt, én a négyszöget választottam.
String polygone = "POLYGON((" + (horeca.getLongitude() - 0.32) + " " + (horeca.getLatitude() - 0.22) + ", " + (horeca.getLongitude() + 0.32) + " " + (horeca.getLatitude() - 0.22) + ", " + (horeca.getLongitude() + 0.32) + " " + (horeca.getLatitude() + 0.22) + ", " + (horeca.getLongitude() - 0.32) + " " + (horeca.getLatitude() + 0.22) + ", " + (horeca.getLongitude() - 0.32) + " " + (horeca.getLatitude() - 0.22) + "))";
String query = "SELECT h FROM Horeca h WHERE MBRContains(PolygonFromText('" + polygone + "'), coordinate)";
Megjegyzem a geometriai funkciók elméletileg 5.0-ás verzió óta léteznek MySQL-ben, elég sok helyen olvastam, hogy hibásan működik, meg, hogy aki ilyet akar használjon PostgreSQL-t. Én is belefutottam olyan kellemetlenségbe, ami miatt frissíteni kellett a szervert 5.1.x-re (a SELECT POINT(12, 12) lefutott, de a UPDATE horeca SET coordinate = POINT(longitude, latitude) már nem).
A Budapest szomszédos kerületeinek kérdés-köre nem egy bonyolult dolog, pusztán azért tárgyalom, hogy teljes legyen a kép. Az adatbázis kímélése érdekében ismét egy triggert írtam, ami budapesti cím beszúrása vagy szerkesztése esetén az irányító számból eltárolja a középső 2 karaktert, mint kerületet. Az egységes kereshetőség érdekében hagytam minden kerületet két karakteresre.
DELIMITER //

CREATE TRIGGER SetDistrict BEFORE INSERT ON `horeca`
FOR EACH ROW
BEGIN
    IF NEW.`postal_code` < 2000 THEN
        SET NEW.`district` = SUBSTR(NEW.`postal_code`, 2, 2);
    END IF;
END;

DELIMITER ;
Ezek után létrehoztam egy táblát, amely a szomszédos kerületek mátrixát tartalmazza.
INSERT INTO `district_neighbors` (`reference`, `neighbor`) VALUES
('01', '02'),('01', '12'),('01', '11'),('01', '05'),('02', '12'),('02', '03'),('02', '05'),('02', '13'),('03', '04'),('03', '13'),('04', '13'),('04', '14'),('04', '15'),('05', '06'),('05', '07'),('05', '13'),('06', '07'),('06', '13'),('06', '14'),('07', '08'),('07', '09'),('07', '14'),('08', '09'),('08', '10'),('08', '14'),('09', '10'),('09', '11'),('09', '19'),('09', '20'),('09', '21'),('10', '14'),('10', '16'),('10', '17'),('10', '18'),('10', '19'),('11', '12'),('11', '21'),('11', '22'),('13', '18'),('13', '19'),('13', '20'),('13', '21'),('14', '15'),('14', '16'),('15', '16'),('17', '18'),('18', '19'),('18', '20'),('18', '23'),('19', '20'),('20', '21'),('20', '23'),('21', '22'),('21', '23');
Az alábbi lekérdezéssel pedig könnyedén kinyerhető adott kerület és a vele szomszédosak az adatbázisból.
String query = "SELECT h FROM Horeca h WHERE district = :district OR district IN (SELECT IF(reference = :district, neighbor, reference) FROM district_neighbors WHERE reference = :district OR neighbor = :district)";

2010. április 28., szerda

Weboldal beüzemelése Linux, Apache, Glassfish alapon

Java-s web-alkalmazások fejlesztése során gyorsan felmerül az igény arra, hogy az oldal elérhető legyen mindenféle portszám megadása nélkül, hiszen vég-felhasználóink általában nem szakavatott fejlesztők, vagy rendszer tervező mérnökök, akiktől nem idegen az efféle címzés. A probléma megoldására több lehetséges megoldás is létezik, az általam felvázolt lehetőség csak egy a sok közül. Operációs rendszernek Linuxot választottam, kedvenc alkalmazás-szerverem pedig a Glassfish.
A beállítás lépései:
  • Glassfish domain létrehozása.
# asadmin create-domain --adminport 4848 --savemasterpassword domain1
A savemasterpassword opcióra azért van szükség, hogy a szerver indításakor és leállításakor ne kelljen a masterjelszavat megadni. Ellenkező esetben csak kézzel tudjuk a szervert indítani és/vagy leállítani.
  • Indító script megírása, és a megfelelő runlevel-be helyezése.
#!/bin/sh
ase "$1" in
start)
    ulimit -Hn 10240
    ulimit -Sn 10240
    su glassfish /path_to_glassfish/bin/asadmin start-domain domain1
    ;;
stop)
    su glassfish /path_to_glassfish/bin/asadmin stop-domain domain1
    ;;
restart)
    su glassfish /path_to_glassfish/bin/asadmin stop-domain domain1
    ulimit -Hn 10240
    ulimit -Sn 10240
    su glassfish /path_to_glassfish/bin/asadmin start-domain domain1
    ;;
*)
    echo $"usage: $0 {start|stop|restart}"
    exit 1
esac
Az első említésre méltó dolog, hogy én létrehoztam egy glassfish felhasználót a rendszerben, és annak nevében/jogosultságával telepítettem az alkalmazás-szervert. Ennek elsősorban biztonsági okai vannak, hiszen így az alkalmazás-szerver csak a "mezei" felhasználó hatáskörében tud tevékenykedni. A második dolog ami szemet szúrhat az ulimit parancs. A Linux kernelben meg van határozva, hogy mekkora darab-számú állományt nyithat meg egy alkalmazás/felhasználó. Ez a szám alapértelmezetten 1024, amiből a Glassfish indulás után elhasznál 8-900-at, így eléggé kis terhelés esetén is átlépi a határt. A kiadott ulimit parancs az adott processre, és az abból induló alprocessekre vonatkozik, ezért nem érdemes rendszer-szinten növelni a limitet, elég az init scriptben beállítani a kívánt értéket. A pontos érték megállapítása terheléses teszt után hangolható, ám kezdésnek érdemes 10240-re venni.
  • Apache web-szerver beállítása
NameVirtualHost *:80

<VirtualHost *:80>
    ServerName www.foo.bar
    ProxyPass / http://localhost:8080/foo.bar-war/
    ProxyPassReverse / http://localhost:8080/foo.bar-war/
    ProxyPassReverseCookieDomain localhost:8080/foo.bar-war www.foo.bar
    ProxyPassReverseCookiePath / /
    ProxyVia Off
    ProxyPreserveHost On
</VirtualHost>
Mivel a felhasználók az URL begépelésével a szerver 80-as portjára csatlakoznak, ésszerű megoldás, ha egy web-szervert telepítünk erre a portra, és a web-szerverből proxyzzuk át a megfelelő kéréseket az alkalmazás-szerver felé. A proxyzáshoz a mod_proxy és mod_proxy_http modulokat kell betölteni. A ProxyPass és ProxyPassReverse opciókkal magát a proxyzás útvonalát állítjuk be, míg a ProxyPassReverseCookieDomain és ProxyPassReverseCookiePath opciókkal a Cookie-k tárolásának módját írjuk elő. Az utóbbi 2 opció elhagyása esetén nem tudjuk a tárolt Cookie-kat elérni, mivel azok a www.foo.bar domainen lesznek bejegyezve, ebből kifolyólag Session azonosítót sem tudunk Cookie-ban tárolni.

2010. április 27., kedd

JDBC loggolás jdbcdslog segítségével

Találtam egy remek kis eszközt, amely képes loggolni a JDBC rétegben történt eseményeket. Az SQL-ek mellett eltárolja a lekérdezések eredményeit is, így pontosan képet kaphatunk arról, hogy mi is történik valójában az adatbázis rétegben. Az alkalmzás a jdbcdslog nevet viseli, és a Google Code szolgáltatáson keresztül érhető el nyílt forráskóddal.
Telepítése pár lépésben:
  • Az aktuális verzió beszerezhető az oldalról. Érdemes a "distribution" kiadást letölteni, mert abban van pár függőség is csomagolva.
  • Az Apache Log4j-re épül a logger, így azt is érdemes beszerezni.
  • Mivel a példa-program a legegyszerűbb módozatot mutatja be, így én a jdbcdslog forrását bemásoltam az alkalmazásomba, illetve a slf4j-api-1.5.10.jar, slf4j-log4j12-1.5.10.jar, log4j-1.2.16.jar jar-okat importáltam. (A jdbcdslog forrásában van hiba, azokat érdemes figyelmen kívül hagyni fordításkor!)
A telepítésről bővebben.
Ezután következhet a kapcsolódás megírása. A dolog nyitja abban rejlik, hogy a JDBC kéréseket a jdbcdslog proxyzza át a JDBC providernek, ehhez a kapcsolatot át kell adni a loggernek.
Connection conn = null;
try {
    PropertyConfigurator.configure(Main.class.getResource("log4j.properties").getFile());
    conn = DriverManager.getConnection("jdbc:mysql://localhost/mysql", "root", "password");
    Connection loggingConnection = ConnectionLoggingProxy.wrap(conn);
    Statement statement = loggingConnection.createStatement();
    statement.executeQuery("SELECT * FROM user");
    ResultSet resultSet = statement.getResultSet();
    while (resultSet.next()) {
        System.out.println(resultSet.getString("User"));
    }
    statement.close();
    resultSet.close();
} catch (Exception e) {
} finally {
    if (conn != null) {
        try {
            conn.close();
        } catch (Exception e) {}
    }
}
A Log4j beállítása a log4j.properties fájlban történik, ennek rejtelmeibe nem mélyednék bele, az egy külön bejegyzést igényelne.
#Create logger named A1
log4j.rootLogger=DEBUG, A1
#Write log to test.log over FileAppender
log4j.appender.A1=org.apache.log4j.FileAppender
log4j.appender.A1.File=test.log
log4j.appender.A1.Append=true
#Set the layout to PatternLayout and set pattern
log4j.appender.A1.layout=org.apache.log4j.PatternLayout
log4j.appender.A1.layout.ConversionPattern=%4d{dd MMM yyyy HH:mm:ss,SSS} %-5p - %c: %m\n
Futtatva kódunkat az alábbi eredményt kapjuk. Én egy frissen telepített MySQL adatbázis mysql táblájából olvastam ki a felhasználókat.
root
root
root
A test.log tartalma:
27 Apr 2010 14:36:43,992 DEBUG - org.jdbcdslog.LogUtils: createLogEntry()
27 Apr 2010 14:36:43,993 INFO  - org.jdbcdslog.StatementLogger: java.sql.Statement.executeQuery SELECT * FROM user 110 ms.
27 Apr 2010 14:36:43,999 INFO  - org.jdbcdslog.ResultSetLogger: java.sql.ResultSet.next {'localhost', 'root', '*080C34F746094F116AE54467CF39EA994A0FF57F', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', '', [B@4f80d6, [B@4f80d6, [B@4f80d6, 0, 0, 0, 0}
27 Apr 2010 14:36:44,003 INFO  - org.jdbcdslog.ResultSetLogger: java.sql.ResultSet.next {'linux-brsg', 'root', '', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', '', [B@4f80d6, [B@4f80d6, [B@4f80d6, 0, 0, 0, 0}
27 Apr 2010 14:36:44,004 INFO  - org.jdbcdslog.ResultSetLogger: java.sql.ResultSet.next {'127.0.0.1', 'root', '', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', '', [B@4f80d6, [B@4f80d6, [B@4f80d6, 0, 0, 0, 0}
27 Apr 2010 14:36:44,007 INFO  - org.jdbcdslog.ResultSetLogger: java.sql.ResultSet.next {'localhost', '', '', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', '', [B@4f80d6, [B@4f80d6, [B@4f80d6, 0, 0, 0, 0}
27 Apr 2010 14:36:44,008 INFO  - org.jdbcdslog.ResultSetLogger: java.sql.ResultSet.next {'linux-brsg', '', '', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', 'N', '', [B@4f80d6, [B@4f80d6, [B@4f80d6, 0, 0, 0, 0}
Perzisztes réteg használata esetén természetesen az itt vázol megoldás nem kivitelezhető, de szerencsére a fejlesztők két további módszert is építettek az alkalmazásba. Van lehetőség "JDBC Driver" és "JDBC DataSource" proxyzásra is, ezeket választva csak a kapcsolódási URL-t kell módosítani, az alkalmazásunk érintetlen marad.

2010. április 14., szerda

HTTPS kapcsolódás self-signed és/vagy lejárt tanusítvánnyal ellátott szolgáltatáshoz

Egy on-line fizetéses alkalmazás fejlesztése közben futottam bele abba a problémába, hogy https protokollon keresztül kell kapcsolódni a banki rendszerhez. Első hallásra nem is tűnik olyan bonyolultnak a dolog, azonban a megvalósítás közben belefutottam egy igen kellemetlen körülménybe. A teszt rendszer tanusítványa lejárt. Az ügyeletes rendszer-gazdával konzultálva megnyugtatott, hogy az éles rendszerben nem lesz probléma,... egy darabig,... még jó, hogy szóltam! Álmomban sem gondoltam volna, hogy ilyen előfordulhat egy banknál, mégha tesztrendszerről is van szó.
A kapcsolódáshoz a jakarta.commons.httpclient-et választottam, abból is a 3.1-es verziót.
HttpClient httpclient = new HttpClient();
Protocol myhttps = new Protocol("https", new SSLProtocolSocketFactory(), 443);
httpclient.getHostConfiguration().setHost("somehost.tld", 443, myhttps);
GetMethod method = new GetMethod("/some_uri"); //PostMethod to post data

try {
    httpclient.executeMethod(method);
    String response = new String(method.getResponseBody());
} finally {
    method.releaseConnection();
}
A kódot futtatva az alábbi Exception-t kaptam:
javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
Első teendőnk ebben az esetben, hogy megszerezzük a tanusítvány publikus részét, majd letöltve hozzáadjuk egy kulcs-tartóhoz, az alábbi paranccsal:
keytool -importcert -trustcacerts -alias untrustedCert -file somecert.crt -keystore /usr/lib/jvm/java/jre/lib/security/cacerts
Mivel én fejlesztői környezetben szeretnék kapcsolódni, a Java alapértelmezett kulcs-tartójához adtam hozzá a tanusítványt, az alapértelmezett "changeit" jelszó segítségével. Ezek után nincs más dolgunk, mint a JVM-nek átadni paraméterként, hogy a kiszemelt kulcs-tartót fogadja el mindenképpen hitelesnek.
-Djavax.net.ssl.trustStore=/usr/lib/jvm/java/jre/lib/security/cacerts

2010. április 4., vasárnap

Képek átméretezése, első nekifutás

Ebben a témában rengeteg bejegyzés található a neten, én személy szerint mégsem találtam elfogadható megoldás, ami egyben megoldaná minden problémámat, kivéve a Java Advanced Imaging-ot. Utóbbival elég nagy problémám volt, hogy nincsen agyondokumentálva, vannak ugyan példák, de azokon eligazodni is felér egy rém-álommal. A rossz tapasztalatok sarkalltak arra, hogy belekezdjek egy, az én igényeimet kielégítő, eszköz megírásába, mely az alapvető webes környezetben előforduló problémákat orvosolja. A projekt elérkezett az első "mérföld-kőhöz", így gondoltam szánok rá pár sort.
Jelenlegi stádiumban képes átméretezni jpg, png, gif, anim gif formátumokat, alkalmazza az élsimítás technológiát, viszont a transzparens képekbe még beletörik a foga. A művelet pilléreit szeretném itt bemutatni, a teljes forráskódot elérhetővé teszem, de mivel felhasználtam pár osztályt, amit letöltöttem és javítottam, ezek pontos liszenszelését még egyeztetem.
Első esetben nézzük az egyszerűbb oldalát a dolognak, amikor nem animált gif-et kell átméretezni. Mivel a legtöbb példa csak a Graphics2D beépített élsimítását használta fel, aminek minősége finoman szólva is vacak, így átméretezés előtt egy blur effektet húzok a képre. A példa csak a kicsinyítés problémájával foglalkozik, mivel a jelentős minőség-romlás ilyen esetben érzékelhető igazán, és a gyakorlatban is ritkán kell nagyítani egy felhasználó által feltöltött képet.
OutputStream out;
BufferedImage bufferedImage;
String extension;
//...
if (!extension.equals("gif")) {
//Első lépésként, amenniyben jpg-a forrás kép, át kell állítanunk a típusát, mivel az alapértelmezett típus nem támogatja a blur készítését, minden más esetben, kompatibilissé tesszük a képet, szintén a blur-ozhatóság érdekében.
    if (mimeType.equals("image/jpeg") || mimeType.equals("image/pjpeg")) {
        bufferedImage = convert(bufferedImage, BufferedImage.TYPE_INT_RGB);
    } else {
        bufferedImage = createCompatibleImage(bufferedImage);
    }

//Elvégezzük a blur-ozást, és átméretezést, majd mehet a kép a kimenetre.
    ImageIO.write(resize(blur(bufferedImage), newWidth.intValue(), newHeight.intValue()), extension, out);

    out.flush();
    out.close();
}
A meghívott metódusok:
//Átméretezi a képet.
private BufferedImage resize(BufferedImage image, int width, int height) {
    BufferedImage result = new BufferedImage(width, height, image.getType() == 0 ? BufferedImage.TYPE_INT_ARGB : image.getType());
    Graphics2D g = result.createGraphics();
    g.setComposite(AlphaComposite.Src);
    g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
    g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    g.drawImage(image, 0, 0, width, height, null);
    g.dispose();

    return result;
}

//Blur-ozza a képet.
private BufferedImage blur(BufferedImage image) {
    float ninth = 1.0f / 9.0f;
    float[] blurKernel = {
        ninth, ninth, ninth,
        ninth, ninth, ninth,
        ninth, ninth, ninth
    };

    Map map = new HashMap();
    map.put(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
    map.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
    map.put(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

    BufferedImageOp op = new ConvolveOp(new Kernel(3, 3, blurKernel), ConvolveOp.EDGE_NO_OP, new RenderingHints(map));

    return op.filter(image, null);
}

//Átalakítja a típusát a képnek.
private BufferedImage convert(BufferedImage image, int type) {
    BufferedImage result = new BufferedImage(image.getWidth(), image.getHeight(), type);
    Graphics2D g = result.createGraphics();
    g.drawRenderedImage(image, null);
    g.dispose();

    return result;
}

//Kompatibilissé teszi a képet.
private static BufferedImage createCompatibleImage(BufferedImage image) {
    GraphicsConfiguration gc = BufferedImageGraphicsConfig.getConfig(image);
    BufferedImage result = gc.createCompatibleImage(image.getWidth(), image.getHeight(), Transparency.BITMASK);
    Graphics2D g = result.createGraphics();
    g.drawRenderedImage(image, null);
    g.dispose();

    return result;
}
A nehezebb eset, mint említettem, animált gifek átméretezése, mivel a Graphics2D nem támogatja alapértelmezetten azokat. A megoldás nem egyedüli érdemem, bár a letöltött kódokat javítanom kellett. A lényeg, hogy a gif kép-kockáin egyesével végig kell iterálni, és az előbb említett műveletek végrehajtása után, a cél képbe beletenni az immár átméretezett példányokat.
try {
    GifDecoder d = new GifDecoder();
    d.read(image.getAbsolutePath());

    AnimatedGifEncoder e = new AnimatedGifEncoder();
    e.setRepeat(d.getLoopCount());
    e.start(out);

    int type = bufferedImage.getType() == 0 ? BufferedImage.TYPE_INT_ARGB : bufferedImage.getType();

    for (int i = 0; i < d.getFrameCount(); i++) {
        e.setDelay(d.getDelay(i));
                    
        BufferedImage frameBuffer = new BufferedImage(origWidth, origHeight, BufferedImage.TYPE_BYTE_INDEXED);
        frameBuffer.getGraphics().drawImage(d.getFrame(i), 0, 0, null);

        if (mimeType.equals("image/jpeg") || mimeType.equals("image/pjpeg")) {
            frameBuffer = convert(frameBuffer, BufferedImage.TYPE_INT_RGB);
        } else {
            frameBuffer = createCompatibleImage(frameBuffer);
        }

        e.addFrame(resize(blur(frameBuffer), newWidth.intValue(), newHeight.intValue()));
    }

    e.finish();
    out.flush();
    out.close();
} catch (Exception e) {}
A javított osztályokat egyenlőre pastebin.com-ra töltöttem fel.

Nem állítom, hogy ez a legjobb és legszebb megoldás, és azt sem, hogy tökéletes eredményt hoz minden típusú képnél, csak azt tudom, hogy a transzparens képek kivételével eddig bevált.